@anthusai/papyrus 2.0.0-next.8 → 2.0.0-next.80

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 (211) hide show
  1. package/amplify/data/function-handler.js +20 -0
  2. package/amplify/data/function-handler.ts +40 -0
  3. package/amplify/data/resource.js +151 -61
  4. package/amplify/data/resource.ts +157 -61
  5. package/amplify/data/schema.js +2096 -1980
  6. package/amplify/data/schema.ts +198 -69
  7. package/amplify/functions/assignment-action/data-access.js +14 -15
  8. package/amplify/functions/assignment-action/data-access.ts +13 -14
  9. package/amplify/functions/assignment-action/handler.ts +1 -1
  10. package/amplify/functions/automation-view/handler.py +17 -0
  11. package/amplify/functions/automation-view/resource.js +24 -0
  12. package/amplify/functions/automation-view/resource.ts +34 -0
  13. package/amplify/functions/automation-view/schema.js +26 -0
  14. package/amplify/functions/automation-view/schema.ts +28 -0
  15. package/amplify/functions/automation-view/wire.js +56 -0
  16. package/amplify/functions/automation-view/wire.ts +76 -0
  17. package/amplify/functions/content-actions/handler.py +61 -2
  18. package/amplify/functions/content-actions/requirements.txt +1 -0
  19. package/amplify/functions/content-actions/resource.js +1 -1
  20. package/amplify/functions/content-actions/resource.ts +1 -1
  21. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
  22. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  23. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
  24. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
  25. package/amplify/functions/editorial-checks/schema.js +122 -0
  26. package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
  27. package/amplify/functions/editorial-checks/wire.js +53 -0
  28. package/amplify/functions/editorial-checks/wire.ts +61 -0
  29. package/amplify/functions/monitoring-view/handler.py +11 -0
  30. package/amplify/functions/monitoring-view/resource.js +24 -0
  31. package/amplify/functions/monitoring-view/resource.ts +34 -0
  32. package/amplify/functions/monitoring-view/schema.js +17 -0
  33. package/amplify/functions/monitoring-view/schema.ts +19 -0
  34. package/amplify/functions/monitoring-view/wire.js +37 -0
  35. package/amplify/functions/monitoring-view/wire.ts +47 -0
  36. package/amplify/functions/public-forms/core.js +247 -0
  37. package/amplify/functions/public-forms/core.ts +310 -0
  38. package/amplify/functions/public-forms/handler.ts +41 -0
  39. package/amplify/functions/public-forms/origins.js +35 -0
  40. package/amplify/functions/public-forms/origins.ts +43 -0
  41. package/amplify/functions/public-forms/resource.js +9 -0
  42. package/amplify/functions/public-forms/resource.ts +10 -0
  43. package/amplify/functions/public-forms/wire.js +25 -0
  44. package/amplify/functions/public-forms/wire.ts +32 -0
  45. package/amplify/functions/shared/editorial-context.ts +63 -4
  46. package/amplify/functions/shared/python-bundle.js +20 -14
  47. package/amplify/functions/shared/python-bundle.ts +25 -18
  48. package/amplify/plugins/newsletter/config.js +245 -0
  49. package/amplify/plugins/newsletter/config.ts +305 -0
  50. package/amplify/plugins/newsletter/function/handler.py +6 -0
  51. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  52. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  53. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  54. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  55. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  56. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  57. package/amplify/plugins/newsletter/schema.js +57 -0
  58. package/amplify/plugins/newsletter/schema.ts +62 -0
  59. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  60. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  61. package/amplify/plugins/newsletter/sending.js +140 -0
  62. package/amplify/plugins/newsletter/sending.ts +186 -0
  63. package/amplify/plugins/reader-steering/config.js +182 -0
  64. package/amplify/plugins/reader-steering/config.ts +290 -0
  65. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  66. package/amplify/plugins/reader-steering/function/handler.py +1554 -0
  67. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  68. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  69. package/amplify/plugins/reader-steering/function/proposals.py +255 -0
  70. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  71. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  72. package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
  73. package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
  74. package/amplify/plugins/reader-steering/schema.js +205 -0
  75. package/amplify/plugins/reader-steering/schema.ts +207 -0
  76. package/amplify/plugins/registry.js +4 -2
  77. package/amplify/plugins/registry.ts +4 -2
  78. package/amplify/plugins/signup/schema.js +2 -1
  79. package/amplify/plugins/signup/schema.ts +2 -1
  80. package/amplify/plugins/types.ts +5 -0
  81. package/amplify/plugins/wire.js +1 -0
  82. package/amplify/plugins/wire.ts +1 -0
  83. package/amplify/publication/projection-lease.js +30 -0
  84. package/amplify/publication/projection-lease.ts +39 -0
  85. package/amplify/publication/redirect-and-index.js +105 -0
  86. package/amplify/publication/redirect-rules.js +73 -0
  87. package/amplify/publication/redirect-rules.ts +93 -0
  88. package/amplify/publication/s3-projection.js +7 -1
  89. package/amplify/publication/s3-projection.ts +9 -1
  90. package/amplify/site-backend-config.ts +18 -1
  91. package/amplify/site-backend-names.js +4 -2
  92. package/amplify/site-backend-names.ts +4 -2
  93. package/amplify/site-backend.js +41 -2
  94. package/amplify/site-backend.ts +46 -2
  95. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  96. package/app/api/newsletter/confirm/route.ts +13 -0
  97. package/app/api/newsletter/preferences/route.ts +13 -0
  98. package/app/api/newsletter/state/route.ts +9 -0
  99. package/app/api/newsletter/subscribe/route.ts +9 -0
  100. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  101. package/app/api/newsroom/curation/context/route.ts +9 -0
  102. package/app/api/newsroom/curation/priming/route.ts +9 -0
  103. package/app/api/newsroom/curation/publication/route.ts +9 -0
  104. package/app/api/reader/comment/delete/route.ts +9 -0
  105. package/app/api/reader/comment/edit/route.ts +9 -0
  106. package/app/api/reader/comment/route.ts +9 -0
  107. package/app/api/reader/flag/route.ts +9 -0
  108. package/app/api/reader/handle/route.ts +9 -0
  109. package/app/api/reader/me/route.ts +9 -0
  110. package/app/api/reader/rating/route.ts +9 -0
  111. package/app/api/reader/report/route.ts +9 -0
  112. package/app/api/reader/retired/route.ts +9 -0
  113. package/app/api/reader/submit/route.ts +9 -0
  114. package/app/api/reader/vote/route.ts +9 -0
  115. package/bin/papyrus-infra.mjs +20 -1
  116. package/components/automation-panel.tsx +49 -0
  117. package/components/automation-run-now.tsx +97 -0
  118. package/components/automation-view.tsx +192 -0
  119. package/components/editorial-checks-admin-panel.tsx +36 -11
  120. package/components/moderation-panel.tsx +248 -0
  121. package/components/monitoring-overview-view.tsx +241 -0
  122. package/components/monitoring-panel.tsx +40 -0
  123. package/components/news-desk-client-provider.tsx +18 -18
  124. package/components/news-desk-taxonomy-client.ts +28 -8
  125. package/components/newsletter-panel.tsx +474 -0
  126. package/components/newsletter-sections.tsx +163 -0
  127. package/components/newsroom-access-gate.tsx +15 -1
  128. package/components/newsroom-article-editor.tsx +6 -6
  129. package/components/newsroom-articles-view.tsx +6 -6
  130. package/components/newsroom-board-view.tsx +8 -3
  131. package/components/newsroom-curation-bootstrap.tsx +45 -0
  132. package/components/newsroom-ops-overview.tsx +22 -92
  133. package/components/newsroom-ops-shell.tsx +2 -2
  134. package/components/newsroom-organizer.css +33 -0
  135. package/components/newsroom-overview-skeleton.tsx +25 -0
  136. package/components/newsroom-references-view.tsx +192 -49
  137. package/components/notification-center.tsx +273 -0
  138. package/components/publication-cyclotron-panel.tsx +15 -10
  139. package/components/reader-auth-state.ts +1 -1
  140. package/components/reference-add-entry.tsx +75 -0
  141. package/components/reference-curation-controls.tsx +31 -22
  142. package/components/reference-cyclotron-priming.tsx +58 -19
  143. package/components/reference-cyclotron-suggestion.tsx +10 -6
  144. package/components/reference-priming.tsx +14 -10
  145. package/components/reference-relevance-review.tsx +3 -3
  146. package/components/reference-section-suggestion.tsx +1 -1
  147. package/components/topic-steering-workspace.tsx +223 -144
  148. package/components/use-editorial-checks.ts +12 -10
  149. package/infra/amplify-app-shell.js +94 -16
  150. package/infra/amplify-app-shell.ts +106 -18
  151. package/infra/cloud-email-config.js +32 -0
  152. package/infra/cloud-email-config.ts +39 -0
  153. package/infra/cloud-email.js +57 -0
  154. package/infra/cloud-email.test.js +69 -0
  155. package/infra/cloud-email.test.ts +66 -0
  156. package/infra/cloud-email.ts +60 -0
  157. package/infra/index.js +1 -0
  158. package/infra/secure-string-parameter-handler.js +41 -0
  159. package/infra/secure-string-parameter-handler.ts +41 -0
  160. package/infra/secure-string-parameter.js +79 -0
  161. package/infra/secure-string-parameter.ts +91 -0
  162. package/infra/site-config.js +88 -6
  163. package/infra/site-config.ts +134 -6
  164. package/lib/automation-client.ts +54 -0
  165. package/lib/automation-fixture.ts +93 -0
  166. package/lib/automation-overview.ts +232 -0
  167. package/lib/category-repository.ts +11 -9
  168. package/lib/curation-routes.ts +345 -0
  169. package/lib/cyclotron-suggestions.ts +4 -4
  170. package/lib/editorial-checks-backend.ts +1 -1
  171. package/lib/editorial-checks-client.ts +19 -1
  172. package/lib/editorial-checks-demo.ts +2 -2
  173. package/lib/editorial-checks-settings.ts +3 -0
  174. package/lib/editorial-checks.ts +8 -11
  175. package/lib/editorial-preview.ts +5 -5
  176. package/lib/monitoring-client.ts +29 -0
  177. package/lib/monitoring-overview-fixture.ts +51 -0
  178. package/lib/monitoring-overview.ts +145 -0
  179. package/lib/news-desk-session.ts +12 -21
  180. package/lib/newsletter-api.ts +418 -0
  181. package/lib/newsletter-client.ts +46 -0
  182. package/lib/newsletter-fixture.ts +184 -0
  183. package/lib/newsletter-model.ts +202 -0
  184. package/lib/newsroom-organizer-nav.ts +2 -1
  185. package/lib/prospectus-session.ts +15 -14
  186. package/lib/prospectus-sign-in-routes.ts +84 -19
  187. package/lib/prospectus-site-identity.ts +24 -1
  188. package/lib/reader-api.ts +145 -0
  189. package/lib/reader-moderation.ts +220 -0
  190. package/lib/reader-retirement-route.ts +89 -0
  191. package/lib/reader-subject.md +29 -0
  192. package/lib/reader-subject.ts +53 -0
  193. package/lib/reference-review-details.ts +71 -0
  194. package/lib/reference-review-request.ts +14 -0
  195. package/lib/relevance-decisions.ts +42 -10
  196. package/lib/relevance-status.ts +2 -2
  197. package/lib/review-client.ts +99 -0
  198. package/lib/review-fixture.ts +45 -0
  199. package/lib/review-requests.ts +238 -0
  200. package/lib/semantic-graph.ts +2 -0
  201. package/lib/site-brand-tenant.ts +11 -6
  202. package/lib/site-brand.ts +6 -3
  203. package/lib/site-origin.ts +67 -0
  204. package/lib/use-reference-review-data.ts +44 -0
  205. package/middleware.ts +14 -3
  206. package/package.json +56 -3
  207. package/routes.manifest.json +215 -2
  208. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  209. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
  210. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
  211. package/amplify/plugins/editorial-checks/schema.js +0 -94
@@ -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
+ }
@@ -0,0 +1,310 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+
3
+ /**
4
+ * The public form receiving endpoint, without any AWS in it: `handlePublicFormRequest` takes the Function URL
5
+ * event plus a store and a rate limiter, so every rule here is exercised by `scripts/test-public-forms.ts`.
6
+ *
7
+ * Contract (docs/public-forms.md): POST JSON from one of the site's own origins; the answer never reflects input.
8
+ */
9
+
10
+ export const FORM_SUBMISSION_MESSAGE_KIND = "form_submission";
11
+ export const FORM_SUBMISSION_MESSAGE_DOMAIN = "inbox";
12
+ export const FORM_SUBMISSION_FEED_KEY = "messages";
13
+ export const NEWSLETTER_FORM_ID = "newsletter";
14
+ export const NEWSLETTER_MESSAGE_TYPE = "newsletter_signup";
15
+ export const DEFAULT_FORM_ID = "contact";
16
+ export const DEFAULT_HONEYPOT_FIELDS = ["website"];
17
+
18
+ export type PublicFormsConfig = {
19
+ /** Exact origins (scheme + host [+ port]) the browser may post from. Empty: every request is refused. */
20
+ allowedOrigins: string[];
21
+ /** Field names that, when filled, mark a bot. */
22
+ honeypotFields: string[];
23
+ maxBodyBytes: number;
24
+ maxFields: number;
25
+ maxValueLength: number;
26
+ /** Submissions one address may make per `ipWindowSeconds`, across all forms. */
27
+ ipLimit: number;
28
+ ipWindowSeconds: number;
29
+ /** Submissions one form accepts per hour from everyone; and the whole site across all form ids. */
30
+ formLimitPerHour: number;
31
+ siteLimitPerHour: number;
32
+ };
33
+
34
+ export const DEFAULT_PUBLIC_FORMS_CONFIG: PublicFormsConfig = {
35
+ allowedOrigins: [],
36
+ honeypotFields: DEFAULT_HONEYPOT_FIELDS,
37
+ maxBodyBytes: 16 * 1024,
38
+ maxFields: 20,
39
+ maxValueLength: 5000,
40
+ ipLimit: 10,
41
+ ipWindowSeconds: 600,
42
+ formLimitPerHour: 120,
43
+ siteLimitPerHour: 400,
44
+ };
45
+
46
+ export type FormSubmissionRecord = {
47
+ id: string;
48
+ messageKind: typeof FORM_SUBMISSION_MESSAGE_KIND;
49
+ messageDomain: string;
50
+ messageType: string;
51
+ status: "active";
52
+ summary: string;
53
+ content: string;
54
+ source: string;
55
+ authorLabel: string;
56
+ searchVisibility: "private";
57
+ responseTarget: "none";
58
+ responseStatus: "COMPLETED";
59
+ metadata: string;
60
+ createdAt: string;
61
+ updatedAt: string;
62
+ newsroomFeedKey: string;
63
+ };
64
+
65
+ export type SubmissionStore = { save(record: FormSubmissionRecord): Promise<void> };
66
+
67
+ /** `hit` counts one event in the window and answers whether it is still within `limit`. */
68
+ export type RateLimiter = {
69
+ hit(scope: string, subject: string, windowSeconds: number, limit: number): Promise<boolean>;
70
+ };
71
+
72
+ export type PublicFormsDeps = {
73
+ store: SubmissionStore;
74
+ limiter: RateLimiter;
75
+ now?: () => Date;
76
+ newId?: () => string;
77
+ };
78
+
79
+ export type FunctionUrlEvent = {
80
+ rawQueryString?: string;
81
+ headers?: Record<string, string | undefined>;
82
+ body?: string | null;
83
+ isBase64Encoded?: boolean;
84
+ requestContext?: { http?: { method?: string; sourceIp?: string } };
85
+ };
86
+
87
+ export type FunctionUrlResult = { statusCode: number; headers: Record<string, string>; body: string };
88
+
89
+ const FORM_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
90
+ const FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]{0,39}$/;
91
+ const EMAIL = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
92
+ const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
93
+
94
+ export function parseOrigins(value: string | undefined): string[] {
95
+ const origins: string[] = [];
96
+ for (const entry of (value ?? "").split(",")) {
97
+ const trimmed = entry.trim();
98
+ if (trimmed === "") continue;
99
+ try {
100
+ const url = new URL(trimmed);
101
+ if (url.protocol === "https:" || url.hostname === "localhost") origins.push(url.origin);
102
+ } catch {
103
+ // An unparsable origin is dropped: the allow-list can only get stricter.
104
+ }
105
+ }
106
+ return [...new Set(origins)];
107
+ }
108
+
109
+ function positiveInteger(value: string | undefined, fallback: number): number {
110
+ const parsed = Number.parseInt(value ?? "", 10);
111
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
112
+ }
113
+
114
+ export function configFromEnvironment(env: Record<string, string | undefined>): PublicFormsConfig {
115
+ const honeypots = (env.PAPYRUS_PUBLIC_FORMS_HONEYPOTS ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
116
+ return {
117
+ ...DEFAULT_PUBLIC_FORMS_CONFIG,
118
+ allowedOrigins: parseOrigins(env.PAPYRUS_PUBLIC_FORMS_ORIGINS),
119
+ honeypotFields: [...new Set([...DEFAULT_HONEYPOT_FIELDS, ...honeypots])],
120
+ ipLimit: positiveInteger(env.PAPYRUS_PUBLIC_FORMS_IP_LIMIT, DEFAULT_PUBLIC_FORMS_CONFIG.ipLimit),
121
+ formLimitPerHour: positiveInteger(env.PAPYRUS_PUBLIC_FORMS_FORM_LIMIT, DEFAULT_PUBLIC_FORMS_CONFIG.formLimitPerHour),
122
+ siteLimitPerHour: positiveInteger(env.PAPYRUS_PUBLIC_FORMS_SITE_LIMIT, DEFAULT_PUBLIC_FORMS_CONFIG.siteLimitPerHour),
123
+ };
124
+ }
125
+
126
+ function reply(statusCode: number, body: Record<string, unknown> | null, headers: Record<string, string> = {}): FunctionUrlResult {
127
+ return {
128
+ statusCode,
129
+ headers: { "Cache-Control": "no-store", "Content-Type": "application/json", "X-Content-Type-Options": "nosniff", ...headers },
130
+ body: body === null ? "" : JSON.stringify(body),
131
+ };
132
+ }
133
+
134
+ function header(event: FunctionUrlEvent, name: string): string {
135
+ const headers = event.headers ?? {};
136
+ for (const [key, value] of Object.entries(headers)) {
137
+ if (key.toLowerCase() === name && typeof value === "string") return value;
138
+ }
139
+ return "";
140
+ }
141
+
142
+ function clean(value: string, multiline: boolean): string {
143
+ const stripped = value.replace(CONTROL, "").replace(/\r\n?/g, "\n");
144
+ return (multiline ? stripped : stripped.replace(/\n/g, " ")).trim();
145
+ }
146
+
147
+ type Parsed =
148
+ | { ok: true; formId: string; fields: Record<string, string>; page: string; honeypotFilled: boolean }
149
+ | { ok: false; error: string };
150
+
151
+ function parseSubmission(event: FunctionUrlEvent, config: PublicFormsConfig): Parsed {
152
+ const raw = event.isBase64Encoded === true ? Buffer.from(event.body ?? "", "base64").toString("utf8") : (event.body ?? "");
153
+ let payload: unknown;
154
+ try {
155
+ payload = JSON.parse(raw);
156
+ } catch {
157
+ return { ok: false, error: "invalid-json" };
158
+ }
159
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return { ok: false, error: "invalid-body" };
160
+ const entries = Object.entries(payload as Record<string, unknown>);
161
+ if (entries.length > config.maxFields) return { ok: false, error: "too-many-fields" };
162
+
163
+ const fields: Record<string, string> = {};
164
+ let formId = "";
165
+ let page = "";
166
+ let honeypotFilled = false;
167
+ for (const [name, value] of entries) {
168
+ if (!FIELD_NAME.test(name)) return { ok: false, error: "invalid-field" };
169
+ if (value === null || value === undefined) continue;
170
+ if (typeof value !== "string") return { ok: false, error: "invalid-field" };
171
+ if (value.length > config.maxValueLength) return { ok: false, error: "field-too-long" };
172
+ if (config.honeypotFields.includes(name)) {
173
+ if (value.trim() !== "") honeypotFilled = true;
174
+ continue;
175
+ }
176
+ if (name === "form") formId = value.trim().toLowerCase();
177
+ else if (name === "page") page = clean(value, false).slice(0, 500);
178
+ else fields[name] = clean(value, name === "message");
179
+ }
180
+ return { ok: true, formId, fields, page, honeypotFilled };
181
+ }
182
+
183
+ function queryFormId(event: FunctionUrlEvent): string {
184
+ const match = /(?:^|&)form=([^&]*)/.exec(event.rawQueryString ?? "");
185
+ if (match === null) return "";
186
+ try {
187
+ return decodeURIComponent(match[1]).trim().toLowerCase();
188
+ } catch {
189
+ return "";
190
+ }
191
+ }
192
+
193
+ function ipSubject(ip: string): string {
194
+ return createHash("sha256").update(`papyrus-public-forms:${ip}`).digest("hex").slice(0, 32);
195
+ }
196
+
197
+ export function buildSubmissionRecord(input: {
198
+ id: string;
199
+ formId: string;
200
+ fields: Record<string, string>;
201
+ page: string;
202
+ origin: string;
203
+ receivedAt: string;
204
+ }): FormSubmissionRecord {
205
+ const { formId, fields } = input;
206
+ const newsletter = formId === NEWSLETTER_FORM_ID;
207
+ const name = fields.name ?? "";
208
+ const email = fields.email ?? "";
209
+ const topic = fields.topic ?? "";
210
+ const message = fields.message ?? "";
211
+ const who = name !== "" ? (email !== "" ? `${name} <${email}>` : name) : email;
212
+ const headline = newsletter ? "Newsletter signup" : topic !== "" ? `${formId}: ${topic}` : formId;
213
+ const lines = [`Form: ${formId}`];
214
+ for (const [key, value] of Object.entries(fields)) {
215
+ if (key === "message" || value === "") continue;
216
+ lines.push(`${key}: ${value}`);
217
+ }
218
+ if (input.page !== "") lines.push(`page: ${input.page}`);
219
+ const content = message !== "" ? `${lines.join("\n")}\n\n${message}` : lines.join("\n");
220
+ const summarySource = message !== "" ? message.replace(/\s+/g, " ") : "";
221
+ const summary = `${headline}${who !== "" ? ` from ${who}` : ""}${summarySource !== "" ? `: ${summarySource}` : ""}`.slice(0, 200);
222
+ return {
223
+ id: input.id,
224
+ messageKind: FORM_SUBMISSION_MESSAGE_KIND,
225
+ messageDomain: FORM_SUBMISSION_MESSAGE_DOMAIN,
226
+ messageType: newsletter ? NEWSLETTER_MESSAGE_TYPE : FORM_SUBMISSION_MESSAGE_KIND,
227
+ status: "active",
228
+ summary,
229
+ content,
230
+ source: "public-form",
231
+ authorLabel: who !== "" ? who.slice(0, 200) : "Anonymous visitor",
232
+ searchVisibility: "private",
233
+ responseTarget: "none",
234
+ responseStatus: "COMPLETED",
235
+ metadata: JSON.stringify({
236
+ kind: newsletter ? NEWSLETTER_MESSAGE_TYPE : FORM_SUBMISSION_MESSAGE_KIND,
237
+ formId,
238
+ topic,
239
+ fields,
240
+ page: input.page,
241
+ origin: input.origin,
242
+ receivedAt: input.receivedAt,
243
+ }),
244
+ createdAt: input.receivedAt,
245
+ updatedAt: input.receivedAt,
246
+ newsroomFeedKey: FORM_SUBMISSION_FEED_KEY,
247
+ };
248
+ }
249
+
250
+ export async function handlePublicFormRequest(
251
+ event: FunctionUrlEvent,
252
+ config: PublicFormsConfig,
253
+ deps: PublicFormsDeps,
254
+ ): Promise<FunctionUrlResult> {
255
+ const method = (event.requestContext?.http?.method ?? "").toUpperCase();
256
+ const origin = header(event, "origin");
257
+ const allowed = origin !== "" && config.allowedOrigins.includes(origin);
258
+ const cors: Record<string, string> = allowed
259
+ ? {
260
+ "Access-Control-Allow-Origin": origin,
261
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
262
+ "Access-Control-Allow-Headers": "Content-Type",
263
+ "Access-Control-Max-Age": "600",
264
+ Vary: "Origin",
265
+ }
266
+ : { Vary: "Origin" };
267
+
268
+ if (method === "OPTIONS") return allowed ? reply(204, null, cors) : reply(403, { ok: false, error: "origin-not-allowed" }, cors);
269
+ if (method !== "POST") return reply(405, { ok: false, error: "method-not-allowed" }, { ...cors, Allow: "POST, OPTIONS" });
270
+ if (!allowed) return reply(403, { ok: false, error: "origin-not-allowed" }, cors);
271
+ if (!/^application\/json\s*(;|$)/i.test(header(event, "content-type"))) return reply(415, { ok: false, error: "unsupported-media-type" }, cors);
272
+
273
+ const bodyBytes = Buffer.byteLength(event.body ?? "", event.isBase64Encoded === true ? "base64" : "utf8");
274
+ if (bodyBytes > config.maxBodyBytes) return reply(413, { ok: false, error: "payload-too-large" }, cors);
275
+
276
+ const parsed = parseSubmission(event, config);
277
+ if (!parsed.ok) return reply(400, { ok: false, error: parsed.error }, cors);
278
+ // A filled honeypot looks like success to the bot and stores nothing.
279
+ if (parsed.honeypotFilled) return reply(200, { ok: true }, cors);
280
+
281
+ const formId = parsed.formId || queryFormId(event) || DEFAULT_FORM_ID;
282
+ if (!FORM_ID.test(formId)) return reply(400, { ok: false, error: "invalid-form" }, cors);
283
+ const { fields } = parsed;
284
+ const email = fields.email ?? "";
285
+ if (email !== "" && (email.length > 254 || !EMAIL.test(email))) return reply(400, { ok: false, error: "invalid-email" }, cors);
286
+ if (formId === NEWSLETTER_FORM_ID && email === "") return reply(400, { ok: false, error: "email-required" }, cors);
287
+ if (email === "" && (fields.message ?? "") === "") return reply(400, { ok: false, error: "empty-submission" }, cors);
288
+
289
+ const subject = ipSubject(event.requestContext?.http?.sourceIp ?? "unknown");
290
+ let within: boolean;
291
+ try {
292
+ within =
293
+ (await deps.limiter.hit("ip", subject, config.ipWindowSeconds, config.ipLimit))
294
+ && (await deps.limiter.hit("form", formId, 3600, config.formLimitPerHour))
295
+ && (await deps.limiter.hit("site", "all", 3600, config.siteLimitPerHour));
296
+ } catch {
297
+ return reply(503, { ok: false, error: "unavailable" }, cors);
298
+ }
299
+ if (!within) return reply(429, { ok: false, error: "rate-limited" }, { ...cors, "Retry-After": "600" });
300
+
301
+ const receivedAt = (deps.now ?? (() => new Date()))().toISOString();
302
+ const id = (deps.newId ?? (() => `form-${randomUUID().replace(/-/g, "")}`))();
303
+ try {
304
+ await deps.store.save(buildSubmissionRecord({ id, formId, fields, page: parsed.page, origin, receivedAt }));
305
+ } catch {
306
+ // Never pretend a submission was received when it was not.
307
+ return reply(500, { ok: false, error: "store-failed" }, cors);
308
+ }
309
+ return reply(200, { ok: true }, cors);
310
+ }
@@ -0,0 +1,41 @@
1
+ import { DynamoDBClient, UpdateItemCommand } from "@aws-sdk/client-dynamodb";
2
+ import { getLambdaDataClient, LAMBDA_DATA_AUTH_MODE } from "../shared/lambda-data-client";
3
+ import {
4
+ configFromEnvironment,
5
+ handlePublicFormRequest,
6
+ type FormSubmissionRecord,
7
+ type FunctionUrlEvent,
8
+ type RateLimiter,
9
+ type SubmissionStore,
10
+ } from "./core";
11
+
12
+ const dynamo = new DynamoDBClient({});
13
+
14
+ /** Counters live in a TTL table; the window number is part of the key, so a window simply stops being read. */
15
+ const limiter: RateLimiter = {
16
+ async hit(scope, subject, windowSeconds, limit) {
17
+ const nowSeconds = Math.floor(Date.now() / 1000);
18
+ const window = Math.floor(nowSeconds / windowSeconds);
19
+ const result = await dynamo.send(
20
+ new UpdateItemCommand({
21
+ TableName: process.env.PAPYRUS_PUBLIC_FORMS_TABLE,
22
+ Key: { pk: { S: `${scope}#${subject}#${windowSeconds}#${window}` } },
23
+ UpdateExpression: "ADD hits :one SET expiresAt = :expires",
24
+ ExpressionAttributeValues: { ":one": { N: "1" }, ":expires": { N: String((window + 2) * windowSeconds) } },
25
+ ReturnValues: "UPDATED_NEW",
26
+ }),
27
+ );
28
+ return Number(result.Attributes?.hits?.N ?? "0") <= limit;
29
+ },
30
+ };
31
+
32
+ const store: SubmissionStore = {
33
+ async save(record: FormSubmissionRecord) {
34
+ const client = await getLambdaDataClient();
35
+ const created = await client.models.Message.create(record as never, { authMode: LAMBDA_DATA_AUTH_MODE });
36
+ if (created.errors?.length) throw new Error("Message create failed");
37
+ },
38
+ };
39
+
40
+ export const handler = (event: FunctionUrlEvent) =>
41
+ handlePublicFormRequest(event, configFromEnvironment(process.env), { store, limiter });
@@ -0,0 +1,35 @@
1
+ function httpsOrigin(value) {
2
+ try {
3
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
4
+ return url.protocol === "https:" && url.hostname !== "localhost" ? url.origin : undefined;
5
+ }
6
+ catch {
7
+ return undefined;
8
+ }
9
+ }
10
+ export function derivePublicFormOrigins(sources) {
11
+ const origins = new Set();
12
+ for (const value of sources.explicit ?? []) {
13
+ try {
14
+ const url = new URL(value);
15
+ if (url.protocol === "https:" || (url.protocol === "http:" && url.hostname === "localhost"))
16
+ origins.add(url.origin);
17
+ else
18
+ throw new Error("not allowed");
19
+ }
20
+ catch {
21
+ throw new Error(`publicForms.allowedOrigins entry ${JSON.stringify(value)} must be an https origin (or http://localhost).`);
22
+ }
23
+ }
24
+ for (const host of sources.hosts ?? []) {
25
+ const origin = host === undefined || host.trim() === "" ? undefined : httpsOrigin(host.trim());
26
+ if (origin !== undefined)
27
+ origins.add(origin);
28
+ }
29
+ for (const value of sources.urls ?? []) {
30
+ const origin = value === undefined || value.trim() === "" ? undefined : httpsOrigin(value.trim());
31
+ if (origin !== undefined)
32
+ origins.add(origin);
33
+ }
34
+ return [...origins].sort();
35
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The browser origins a site's public forms accept: the site's own public hosts, never a wildcard.
3
+ * Pure so that `scripts/test-public-forms.ts` can pin it.
4
+ */
5
+ export type PublicFormsOriginSources = {
6
+ /** `publicForms.allowedOrigins` in the site's backend config: always honoured (https, or localhost for development). */
7
+ explicit?: readonly string[];
8
+ /** Public hosts the site is served from: distribution aliases, the reader domain. Bare hosts become https origins. */
9
+ hosts?: readonly (string | undefined)[];
10
+ /** URLs whose origin is the site's own: the revalidate base URL, the sign-in redirect URLs. https only, never localhost. */
11
+ urls?: readonly (string | undefined)[];
12
+ };
13
+
14
+ function httpsOrigin(value: string): string | undefined {
15
+ try {
16
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
17
+ return url.protocol === "https:" && url.hostname !== "localhost" ? url.origin : undefined;
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ }
22
+
23
+ export function derivePublicFormOrigins(sources: PublicFormsOriginSources): string[] {
24
+ const origins = new Set<string>();
25
+ for (const value of sources.explicit ?? []) {
26
+ try {
27
+ const url = new URL(value);
28
+ if (url.protocol === "https:" || (url.protocol === "http:" && url.hostname === "localhost")) origins.add(url.origin);
29
+ else throw new Error("not allowed");
30
+ } catch {
31
+ throw new Error(`publicForms.allowedOrigins entry ${JSON.stringify(value)} must be an https origin (or http://localhost).`);
32
+ }
33
+ }
34
+ for (const host of sources.hosts ?? []) {
35
+ const origin = host === undefined || host.trim() === "" ? undefined : httpsOrigin(host.trim());
36
+ if (origin !== undefined) origins.add(origin);
37
+ }
38
+ for (const value of sources.urls ?? []) {
39
+ const origin = value === undefined || value.trim() === "" ? undefined : httpsOrigin(value.trim());
40
+ if (origin !== undefined) origins.add(origin);
41
+ }
42
+ return [...origins].sort();
43
+ }