@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
@@ -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
+ }
@@ -13,6 +13,10 @@ export type EditorialOptions = {
13
13
  storyLineageId?: string | null;
14
14
  assignmentId?: string | null;
15
15
  maxCharacters?: number | null;
16
+ /** A publication without a current mission or policy record yields missingPublicationDoctrine instead of an error, and a
17
+ * section value that matches no NewsroomSection yields missingSectionConfiguration instead of an error.
18
+ * Only the editorial check asks for this (its rule lane needs neither); more than one current record is still an error. */
19
+ optionalPublicationDoctrine?: boolean | null;
16
20
  readAttachment: (attachment: EditorialRecord) => Promise<string>;
17
21
  };
18
22
 
@@ -38,6 +42,47 @@ async function get(client: EditorialClient, model: string, id: string) {
38
42
  return row;
39
43
  }
40
44
 
45
+ const slugOf = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
46
+ const localPart = (id: string) => id.split("#").at(-1) ?? id;
47
+
48
+ async function allSections(client: EditorialClient) {
49
+ const rows: EditorialRecord[] = [];
50
+ let nextToken: string | null | undefined;
51
+ do {
52
+ const response = checked(await client.models.NewsroomSection.list({ limit: 100, nextToken }), "read NewsroomSection list");
53
+ rows.push(...(response.data as EditorialRecord[] ?? []));
54
+ nextToken = response.nextToken;
55
+ } while (nextToken);
56
+ return rows;
57
+ }
58
+
59
+ /** An item stores its section as an id, a slug or a name; find the NewsroomSection of this publication it means.
60
+ * Order: id (as given, then tenant-scoped), then slug (the id's local part), then name (title or short title).
61
+ * Returns null when nothing matches; two sections matching at the same step is an error. */
62
+ async function findSection(client: EditorialClient, value: string, tenantId?: string) {
63
+ const direct = checked(await client.models.NewsroomSection.get({ id: value }), "read NewsroomSection").data as EditorialRecord | null;
64
+ if (direct) return direct;
65
+ if (tenantId) {
66
+ const scoped = checked(await client.models.NewsroomSection.get({ id: tenantScopedValue(tenantId, value) }), "read NewsroomSection").data as EditorialRecord | null;
67
+ if (scoped) return scoped;
68
+ }
69
+ const all = tenantId
70
+ ? await pages(client, "NewsroomSection", "listNewsroomSectionsByTenantAndSortOrder", { tenantId })
71
+ : (await allSections(client)).filter(row => !row.tenantId);
72
+ const wanted = slugOf(value);
73
+ const steps: ((row: EditorialRecord) => boolean)[] = [
74
+ row => slugOf(localPart(String(row.id ?? ""))) === wanted,
75
+ row => [row.title, row.shortTitle].some(name => typeof name === "string" && name.trim().toLowerCase() === value.trim().toLowerCase()),
76
+ row => [row.title, row.shortTitle].some(name => typeof name === "string" && slugOf(name) === wanted),
77
+ ];
78
+ for (const step of steps) {
79
+ const matches = all.filter(step);
80
+ if (matches.length > 1) throw new Error(`Section "${value}" is ambiguous: ${matches.map(row => row.id).join(", ")}.`);
81
+ if (matches.length === 1) return matches[0];
82
+ }
83
+ return null;
84
+ }
85
+
41
86
  function tenant(row: EditorialRecord, tenantId?: string) {
42
87
  if ((row.tenantId || null) !== (tenantId || null)) throw new Error("Editorial record tenant does not match the configured authoring scope.");
43
88
  }
@@ -55,7 +100,7 @@ function entry(scope: string, kind: string, text: string, record: EditorialRecor
55
100
  } };
56
101
  }
57
102
 
58
- export function buildEditorialContext(publication: EditorialRecord, scope: EditorialRecord, entries: EditorialRecord[], warnings: string[], maxCharacters = 0) {
103
+ export function buildEditorialContext(publication: EditorialRecord, scope: EditorialRecord, entries: EditorialRecord[], warnings: string[], maxCharacters = 0, missingPublicationDoctrine: string[] = [], missingSectionConfiguration: string[] = []) {
59
104
  const lines = [`Editorial context: ${publication.key}`, "Source: cloud",
60
105
  "Precedence: publication > section > assignment. Surface conflicts; narrower instructions do not silently override publication policy.",
61
106
  "Private operating guidance; not evidence or reader-facing copy."];
@@ -65,7 +110,9 @@ export function buildEditorialContext(publication: EditorialRecord, scope: Edito
65
110
  if (!Number.isInteger(maxCharacters) || maxCharacters < 0) throw new Error("Editorial context budget must be nonnegative.");
66
111
  if (maxCharacters && totalCharacters > maxCharacters) throw new Error(`Editorial context budget ${maxCharacters} cannot hold ${totalCharacters} required characters; policy was not truncated.`);
67
112
  const payload = { schemaVersion: 1, rendererVersion: "editorial-context/v1", publication, scope, sourceMode: "cloud",
68
- precedence: ["publication", "section", "assignment"], entries, sources: entries.map(value => value.source), warnings, text, totalCharacters };
113
+ precedence: ["publication", "section", "assignment"], entries, sources: entries.map(value => value.source), warnings, text, totalCharacters,
114
+ ...(missingPublicationDoctrine.length ? { missingPublicationDoctrine } : {}),
115
+ ...(missingSectionConfiguration.length ? { missingSectionConfiguration } : {}) };
69
116
  return { ...payload, contextHash: digest(JSON.stringify(canonical(payload))), resolvedAt: new Date().toISOString() };
70
117
  }
71
118
 
@@ -83,7 +130,10 @@ export async function resolveEditorialContext(client: EditorialClient, options:
83
130
  corpusId: corpora[0].id, tenantId: options.tenantId ?? null, endpoint: options.endpoint };
84
131
  const entries: EditorialRecord[] = [];
85
132
  const warnings: string[] = [];
133
+ const missingPublicationDoctrine: string[] = [];
86
134
  let sectionId = options.sectionId || null;
135
+ let requestedSection: string | null = null;
136
+ const missingSectionConfiguration: string[] = [];
87
137
  let storyLineageId = options.storyLineageId || null;
88
138
  const assignmentId = options.assignmentId || null;
89
139
  let assignment: EditorialRecord | undefined;
@@ -110,6 +160,7 @@ export async function resolveEditorialContext(client: EditorialClient, options:
110
160
  const rows = await pages(client, "Item", options.tenantId ? "itemByTenantSlug" : "itemBySlug",
111
161
  options.tenantId ? { tenantSlug: tenantScopedValue(options.tenantId, slug) } : { slug });
112
162
  const current = rows.filter(row => row.type === "doctrine" && row.status === "private" && row.versionState === "current");
163
+ if (current.length === 0 && options.optionalPublicationDoctrine) { missingPublicationDoctrine.push(kind); continue; }
113
164
  if (current.length !== 1) throw new Error(`Publication ${kind}: expected exactly one current record, found ${current.length}.`);
114
165
  const record = current[0];
115
166
  tenant(record, options.tenantId);
@@ -118,13 +169,19 @@ export async function resolveEditorialContext(client: EditorialClient, options:
118
169
  entries.push(entry("publication", kind, text, record));
119
170
  }
120
171
  if (sectionId) {
121
- const section = await get(client, "NewsroomSection", sectionId);
172
+ const section = await findSection(client, sectionId, options.tenantId);
173
+ if (!section && !options.optionalPublicationDoctrine) throw new Error(`NewsroomSection not found: ${sectionId}`);
174
+ if (!section) { missingSectionConfiguration.push(sectionId); requestedSection = sectionId; sectionId = null; }
175
+ else {
122
176
  tenant(section, options.tenantId);
177
+ if (section.id !== sectionId) requestedSection = sectionId;
178
+ sectionId = section.id;
123
179
  for (const [kind, key] of [["mission", "editorialMission"], ["policy", "editorialPolicy"], ["guidance", "assignmentGuidance"], ["kill criteria", "killCriteria"]]) {
124
180
  const text = String(section[key] || "").trim();
125
181
  if (text) entries.push(entry("section", kind, text, section));
126
182
  else if (kind === "mission" || kind === "policy") warnings.push(`Section ${section.id} intentionally has an empty ${kind} slot; publication doctrine still applies.`);
127
183
  }
184
+ }
128
185
  }
129
186
  if (assignment) {
130
187
  if (options.tenantId && !storyLineageId && !sectionId) throw new Error("A tenant assignment needs a scoped story or section to establish its publication.");
@@ -147,7 +204,9 @@ export async function resolveEditorialContext(client: EditorialClient, options:
147
204
  warnings.push("No assignment brief attachment exists; the Assignment summary is supplied as its brief.");
148
205
  }
149
206
  }
150
- return buildEditorialContext(publication, { sectionId, storyLineageId, assignmentId }, entries, warnings, options.maxCharacters ?? 0);
207
+ // requestedSection: the value the caller asked for when it was not the section's id (a slug or name, or nothing matched).
208
+ const scope = { sectionId, storyLineageId, assignmentId, ...(requestedSection ? { requestedSection } : {}) };
209
+ return buildEditorialContext(publication, scope, entries, warnings, options.maxCharacters ?? 0, missingPublicationDoctrine, missingSectionConfiguration);
151
210
  }
152
211
 
153
212
  export function assignmentDoctrine(pack: Awaited<ReturnType<typeof resolveEditorialContext>>) {