@anthusai/papyrus 5.5.0 → 5.6.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.
@@ -6,6 +6,7 @@ import { knowledgeQuery } from "../functions/knowledge-query/resource.js";
6
6
  import { manageUserRole } from "../functions/manage-user-role/resource.js";
7
7
  import { modelAttachmentUpload } from "../functions/model-attachment-upload/resource.js";
8
8
  import { newsroomSummary } from "../functions/newsroom-summary/resource.js";
9
+ import { publicForms } from "../functions/public-forms/resource.js";
9
10
  import { procedureAction } from "../functions/procedure-action/resource.js";
10
11
  import { readerSettings } from "../functions/reader-settings/resource.js";
11
12
  import { emailSubmissionProcessor } from "../functions/email-submission-processor/resource.js";
@@ -2165,6 +2166,7 @@ const schema = a.schema({
2165
2166
  allow.resource(modelAttachmentUpload),
2166
2167
  allow.resource(newsroomSummary),
2167
2168
  allow.resource(procedureAction),
2169
+ allow.resource(publicForms).to(["mutate"]),
2168
2170
  allow.resource(sesInboundReceive),
2169
2171
  allow.resource(emailSubmissionProcessor),
2170
2172
  allow.resource(slackEvents),
@@ -6,6 +6,7 @@ import { knowledgeQuery } from "../functions/knowledge-query/resource";
6
6
  import { manageUserRole } from "../functions/manage-user-role/resource";
7
7
  import { modelAttachmentUpload } from "../functions/model-attachment-upload/resource";
8
8
  import { newsroomSummary } from "../functions/newsroom-summary/resource";
9
+ import { publicForms } from "../functions/public-forms/resource";
9
10
  import { procedureAction } from "../functions/procedure-action/resource";
10
11
  import { readerSettings } from "../functions/reader-settings/resource";
11
12
  import { emailSubmissionProcessor } from "../functions/email-submission-processor/resource";
@@ -2292,6 +2293,7 @@ const schema = a.schema({
2292
2293
  allow.resource(modelAttachmentUpload),
2293
2294
  allow.resource(newsroomSummary),
2294
2295
  allow.resource(procedureAction),
2296
+ allow.resource(publicForms).to(["mutate"]),
2295
2297
  allow.resource(sesInboundReceive),
2296
2298
  allow.resource(emailSubmissionProcessor),
2297
2299
  allow.resource(slackEvents),
@@ -8,6 +8,7 @@ import { knowledgeQuery } from "../functions/knowledge-query/resource.js";
8
8
  import { manageUserRole } from "../functions/manage-user-role/resource.js";
9
9
  import { modelAttachmentUpload } from "../functions/model-attachment-upload/resource.js";
10
10
  import { newsroomSummary } from "../functions/newsroom-summary/resource.js";
11
+ import { publicForms } from "../functions/public-forms/resource.js";
11
12
  import { procedureAction } from "../functions/procedure-action/resource.js";
12
13
  import { readerSettings } from "../functions/reader-settings/resource.js";
13
14
  const authoringOperations = [
@@ -2012,6 +2013,7 @@ export const schema = a.schema(baseDefinitions).authorization((allow) => [
2012
2013
  allow.resource(modelAttachmentUpload),
2013
2014
  allow.resource(newsroomSummary),
2014
2015
  allow.resource(procedureAction),
2016
+ allow.resource(publicForms).to(["mutate"]),
2015
2017
  ]);
2016
2018
  /** The deployed schema of a site that enables plugins: the base definitions plus what each plugin contributes (docs/plugins.md). */
2017
2019
  export function schemaWithPlugins(pluginDefinitions) {
@@ -8,6 +8,7 @@ import { knowledgeQuery } from "../functions/knowledge-query/resource";
8
8
  import { manageUserRole } from "../functions/manage-user-role/resource";
9
9
  import { modelAttachmentUpload } from "../functions/model-attachment-upload/resource";
10
10
  import { newsroomSummary } from "../functions/newsroom-summary/resource";
11
+ import { publicForms } from "../functions/public-forms/resource";
11
12
  import { procedureAction } from "../functions/procedure-action/resource";
12
13
  import { readerSettings } from "../functions/reader-settings/resource";
13
14
 
@@ -2133,6 +2134,7 @@ export const schema = a.schema(baseDefinitions).authorization((allow) => [
2133
2134
  allow.resource(modelAttachmentUpload),
2134
2135
  allow.resource(newsroomSummary),
2135
2136
  allow.resource(procedureAction),
2137
+ allow.resource(publicForms).to(["mutate"]),
2136
2138
  ]);
2137
2139
 
2138
2140
  /** The deployed schema of a site that enables plugins: the base definitions plus what each plugin contributes (docs/plugins.md). */
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ /** The public form receiving endpoint. Wired (table, Function URL, origins, output) by `wire.ts`. */
3
+ export const publicForms = defineFunction({
4
+ name: "papyrus-public-forms",
5
+ entry: "./handler.ts",
6
+ timeoutSeconds: 10,
7
+ memoryMB: 256,
8
+ resourceGroupName: "data",
9
+ });
@@ -0,0 +1,10 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+
3
+ /** The public form receiving endpoint. Wired (table, Function URL, origins, output) by `wire.ts`. */
4
+ export const publicForms = defineFunction({
5
+ name: "papyrus-public-forms",
6
+ entry: "./handler.ts",
7
+ timeoutSeconds: 10,
8
+ memoryMB: 256,
9
+ resourceGroupName: "data",
10
+ });
@@ -0,0 +1,25 @@
1
+ import { RemovalPolicy, Stack } from "aws-cdk-lib";
2
+ import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
3
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
4
+ import { FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
5
+ export const PUBLIC_FORMS_OUTPUT_KEY = "publicForms";
6
+ /**
7
+ * Wires the endpoint: the rate-limit counter table (TTL), the Function URL and the function's settings, and returns
8
+ * the URL for the `custom.publicForms.endpoint` output. Everything lives in the function's own stack.
9
+ */
10
+ export function wirePublicForms(backend, wiring) {
11
+ const lambda = backend.publicForms.resources.lambda;
12
+ const stack = Stack.of(lambda);
13
+ const table = new dynamodb.Table(stack, "PublicFormsLimits", {
14
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
15
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
16
+ timeToLiveAttribute: "expiresAt",
17
+ removalPolicy: RemovalPolicy.DESTROY,
18
+ });
19
+ lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem"], resources: [table.tableArn] }));
20
+ backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_TABLE", table.tableName);
21
+ backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_ORIGINS", wiring.allowedOrigins.join(","));
22
+ backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_HONEYPOTS", (wiring.honeypotFields ?? []).join(","));
23
+ // CORS is answered by the handler (it must echo exactly one allowed origin), so the URL carries no CORS block.
24
+ return lambda.addFunctionUrl({ authType: FunctionUrlAuthType.NONE }).url;
25
+ }
@@ -0,0 +1,32 @@
1
+ import { RemovalPolicy, Stack } from "aws-cdk-lib";
2
+ import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
3
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
4
+ import { type Function as LambdaFunction, FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
5
+
6
+ export const PUBLIC_FORMS_OUTPUT_KEY = "publicForms";
7
+
8
+ export type PublicFormsWiring = {
9
+ allowedOrigins: string[];
10
+ honeypotFields?: string[];
11
+ };
12
+
13
+ /**
14
+ * Wires the endpoint: the rate-limit counter table (TTL), the Function URL and the function's settings, and returns
15
+ * the URL for the `custom.publicForms.endpoint` output. Everything lives in the function's own stack.
16
+ */
17
+ export function wirePublicForms(backend: { publicForms: { resources: { lambda: unknown }; addEnvironment: (name: string, value: string) => void } }, wiring: PublicFormsWiring): string {
18
+ const lambda = backend.publicForms.resources.lambda as LambdaFunction;
19
+ const stack = Stack.of(lambda);
20
+ const table = new dynamodb.Table(stack, "PublicFormsLimits", {
21
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
22
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
23
+ timeToLiveAttribute: "expiresAt",
24
+ removalPolicy: RemovalPolicy.DESTROY,
25
+ });
26
+ lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem"], resources: [table.tableArn] }));
27
+ backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_TABLE", table.tableName);
28
+ backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_ORIGINS", wiring.allowedOrigins.join(","));
29
+ backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_HONEYPOTS", (wiring.honeypotFields ?? []).join(","));
30
+ // CORS is answered by the handler (it must echo exactly one allowed origin), so the URL carries no CORS block.
31
+ return lambda.addFunctionUrl({ authType: FunctionUrlAuthType.NONE }).url;
32
+ }
@@ -178,6 +178,16 @@ export type PapyrusSiteBackendConfig = {
178
178
  * Unset: no plugin, and the synthesized backend is exactly what it was before plugins existed.
179
179
  */
180
180
  plugins?: PluginRequest[];
181
+ /**
182
+ * The public form receiving endpoint every site gets (docs/public-forms.md). It accepts posts only from the site's
183
+ * own public hosts (distribution aliases, reader domain, revalidate URL, sign-in redirect URLs) plus these.
184
+ */
185
+ publicForms?: {
186
+ /** Extra https origins (or http://localhost) allowed to post, for example `https://www.example.org`. */
187
+ allowedOrigins?: string[];
188
+ /** Extra honeypot field names besides `website`. */
189
+ honeypotFields?: string[];
190
+ };
181
191
  features?: {
182
192
  consoleResponder?: boolean;
183
193
  inboundEmail?: boolean;
@@ -35,6 +35,9 @@ import { knowledgeQuery } from "./functions/knowledge-query/resource.js";
35
35
  import { manageUserRole } from "./functions/manage-user-role/resource.js";
36
36
  import { modelAttachmentUpload } from "./functions/model-attachment-upload/resource.js";
37
37
  import { newsroomSummary } from "./functions/newsroom-summary/resource.js";
38
+ import { derivePublicFormOrigins } from "./functions/public-forms/origins.js";
39
+ import { publicForms } from "./functions/public-forms/resource.js";
40
+ import { wirePublicForms } from "./functions/public-forms/wire.js";
38
41
  import { procedureAction } from "./functions/procedure-action/resource.js";
39
42
  import { readerSettings } from "./functions/reader-settings/resource.js";
40
43
  import { emailSubmissionProcessor } from "./functions/email-submission-processor/resource.js";
@@ -101,6 +104,7 @@ export function defineSiteBackend(papyrusSite) {
101
104
  modelAttachmentUpload,
102
105
  newsroomSummary,
103
106
  procedureAction,
107
+ publicForms,
104
108
  readerSettings,
105
109
  emailSubmissionProcessor,
106
110
  sesInboundReceive,
@@ -564,8 +568,17 @@ export function defineSiteBackend(papyrusSite) {
564
568
  : []),
565
569
  ],
566
570
  }));
571
+ const publicFormsEndpoint = wirePublicForms(backend, {
572
+ allowedOrigins: derivePublicFormOrigins({
573
+ explicit: site.publicForms?.allowedOrigins,
574
+ hosts: [activeBrand?.readerDeployment?.domain, ...(publication?.distribution?.aliases ?? [])],
575
+ urls: [site.revalidateBaseUrl, ...siteAuthRedirectUrls(authConfig)],
576
+ }),
577
+ honeypotFields: site.publicForms?.honeypotFields,
578
+ });
567
579
  backend.addOutput({
568
580
  custom: {
581
+ publicForms: { endpoint: publicFormsEndpoint },
569
582
  prospectusSignIn: prospectusGoogleOnlyClient
570
583
  ? { clientId: prospectusGoogleOnlyClient.ref, callbackPath: PROSPECTUS_SIGN_IN_CALLBACK_PATH }
571
584
  : null,
@@ -42,6 +42,9 @@ import { knowledgeQuery } from "./functions/knowledge-query/resource";
42
42
  import { manageUserRole } from "./functions/manage-user-role/resource";
43
43
  import { modelAttachmentUpload } from "./functions/model-attachment-upload/resource";
44
44
  import { newsroomSummary } from "./functions/newsroom-summary/resource";
45
+ import { derivePublicFormOrigins } from "./functions/public-forms/origins";
46
+ import { publicForms } from "./functions/public-forms/resource";
47
+ import { wirePublicForms } from "./functions/public-forms/wire";
45
48
  import { procedureAction } from "./functions/procedure-action/resource";
46
49
  import { readerSettings } from "./functions/reader-settings/resource";
47
50
  import { emailSubmissionProcessor } from "./functions/email-submission-processor/resource";
@@ -122,6 +125,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
122
125
  modelAttachmentUpload,
123
126
  newsroomSummary,
124
127
  procedureAction,
128
+ publicForms,
125
129
  readerSettings,
126
130
  emailSubmissionProcessor,
127
131
  sesInboundReceive,
@@ -707,8 +711,18 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
707
711
  }),
708
712
  );
709
713
 
714
+ const publicFormsEndpoint = wirePublicForms(backend, {
715
+ allowedOrigins: derivePublicFormOrigins({
716
+ explicit: site.publicForms?.allowedOrigins,
717
+ hosts: [activeBrand?.readerDeployment?.domain, ...(publication?.distribution?.aliases ?? [])],
718
+ urls: [site.revalidateBaseUrl, ...siteAuthRedirectUrls(authConfig)],
719
+ }),
720
+ honeypotFields: site.publicForms?.honeypotFields,
721
+ });
722
+
710
723
  backend.addOutput({
711
724
  custom: {
725
+ publicForms: { endpoint: publicFormsEndpoint },
712
726
  prospectusSignIn: prospectusGoogleOnlyClient
713
727
  ? { clientId: prospectusGoogleOnlyClient.ref, callbackPath: PROSPECTUS_SIGN_IN_CALLBACK_PATH }
714
728
  : null,
package/package.json CHANGED
@@ -406,11 +406,12 @@
406
406
  "optional": true
407
407
  }
408
408
  },
409
- "version": "5.5.0",
409
+ "version": "5.6.0",
410
410
  "dependencies": {
411
411
  "@aws-amplify/adapter-nextjs": "^1.7.3",
412
412
  "@aws-crypto/sha256-js": "5.2.0",
413
413
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
414
+ "@aws-sdk/client-dynamodb": "^3.1045.0",
414
415
  "@aws-sdk/client-lambda": "^3.1045.0",
415
416
  "@aws-sdk/client-s3": "^3.1045.0",
416
417
  "@aws-sdk/client-secrets-manager": "3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.5.0",
2
+ "version": "5.6.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",