@anthusai/papyrus 2.0.0-next.7 → 2.0.0-next.70

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 (165) hide show
  1. package/amplify/data/resource.js +23 -3
  2. package/amplify/data/resource.ts +25 -3
  3. package/amplify/data/schema.js +2028 -1979
  4. package/amplify/data/schema.ts +72 -14
  5. package/amplify/functions/assignment-action/data-access.js +90 -0
  6. package/amplify/functions/assignment-action/data-access.ts +89 -0
  7. package/amplify/functions/assignment-action/handler.ts +1 -1
  8. package/amplify/functions/content-actions/handler.py +61 -2
  9. package/amplify/functions/content-actions/requirements.txt +1 -0
  10. package/amplify/functions/content-actions/resource.js +1 -1
  11. package/amplify/functions/content-actions/resource.ts +1 -1
  12. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
  13. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  14. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
  15. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
  16. package/amplify/functions/editorial-checks/schema.js +121 -0
  17. package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
  18. package/amplify/functions/editorial-checks/wire.js +53 -0
  19. package/amplify/functions/editorial-checks/wire.ts +61 -0
  20. package/amplify/functions/monitoring-view/handler.py +11 -0
  21. package/amplify/functions/monitoring-view/resource.js +24 -0
  22. package/amplify/functions/monitoring-view/resource.ts +34 -0
  23. package/amplify/functions/monitoring-view/schema.js +16 -0
  24. package/amplify/functions/monitoring-view/schema.ts +18 -0
  25. package/amplify/functions/monitoring-view/wire.js +37 -0
  26. package/amplify/functions/monitoring-view/wire.ts +47 -0
  27. package/amplify/functions/public-forms/core.js +247 -0
  28. package/amplify/functions/public-forms/core.ts +310 -0
  29. package/amplify/functions/public-forms/handler.ts +41 -0
  30. package/amplify/functions/public-forms/origins.js +35 -0
  31. package/amplify/functions/public-forms/origins.ts +43 -0
  32. package/amplify/functions/public-forms/resource.js +9 -0
  33. package/amplify/functions/public-forms/resource.ts +10 -0
  34. package/amplify/functions/public-forms/wire.js +25 -0
  35. package/amplify/functions/public-forms/wire.ts +32 -0
  36. package/amplify/functions/shared/editorial-context.ts +63 -4
  37. package/amplify/functions/shared/python-bundle.js +20 -14
  38. package/amplify/functions/shared/python-bundle.ts +25 -18
  39. package/amplify/plugins/newsletter/config.js +245 -0
  40. package/amplify/plugins/newsletter/config.ts +305 -0
  41. package/amplify/plugins/newsletter/function/handler.py +6 -0
  42. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  43. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  44. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  45. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  46. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  47. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  48. package/amplify/plugins/newsletter/schema.js +56 -0
  49. package/amplify/plugins/newsletter/schema.ts +61 -0
  50. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  51. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  52. package/amplify/plugins/newsletter/sending.js +140 -0
  53. package/amplify/plugins/newsletter/sending.ts +186 -0
  54. package/amplify/plugins/reader-steering/config.js +180 -0
  55. package/amplify/plugins/reader-steering/config.ts +280 -0
  56. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  57. package/amplify/plugins/reader-steering/function/handler.py +1554 -0
  58. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  59. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  60. package/amplify/plugins/reader-steering/function/proposals.py +255 -0
  61. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  62. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  63. package/amplify/plugins/reader-steering/papyrus.plugin.js +184 -0
  64. package/amplify/plugins/reader-steering/papyrus.plugin.ts +210 -0
  65. package/amplify/plugins/reader-steering/schema.js +204 -0
  66. package/amplify/plugins/reader-steering/schema.ts +206 -0
  67. package/amplify/plugins/registry.js +4 -2
  68. package/amplify/plugins/registry.ts +4 -2
  69. package/amplify/plugins/types.ts +5 -0
  70. package/amplify/plugins/wire.js +1 -0
  71. package/amplify/plugins/wire.ts +1 -0
  72. package/amplify/publication/projection-lease.js +30 -0
  73. package/amplify/publication/projection-lease.ts +39 -0
  74. package/amplify/publication/redirect-and-index.js +105 -0
  75. package/amplify/publication/redirect-rules.js +73 -0
  76. package/amplify/publication/redirect-rules.ts +93 -0
  77. package/amplify/publication/s3-projection.js +7 -1
  78. package/amplify/publication/s3-projection.ts +9 -1
  79. package/amplify/site-backend-config.ts +17 -0
  80. package/amplify/site-backend.js +38 -1
  81. package/amplify/site-backend.ts +46 -1
  82. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  83. package/app/api/newsletter/confirm/route.ts +13 -0
  84. package/app/api/newsletter/preferences/route.ts +13 -0
  85. package/app/api/newsletter/state/route.ts +9 -0
  86. package/app/api/newsletter/subscribe/route.ts +9 -0
  87. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  88. package/app/api/newsroom/curation/context/route.ts +9 -0
  89. package/app/api/newsroom/curation/priming/route.ts +9 -0
  90. package/app/api/newsroom/curation/publication/route.ts +9 -0
  91. package/app/api/reader/comment/delete/route.ts +9 -0
  92. package/app/api/reader/comment/edit/route.ts +9 -0
  93. package/app/api/reader/comment/route.ts +9 -0
  94. package/app/api/reader/flag/route.ts +9 -0
  95. package/app/api/reader/handle/route.ts +9 -0
  96. package/app/api/reader/me/route.ts +9 -0
  97. package/app/api/reader/rating/route.ts +9 -0
  98. package/app/api/reader/report/route.ts +9 -0
  99. package/app/api/reader/retired/route.ts +9 -0
  100. package/app/api/reader/submit/route.ts +9 -0
  101. package/app/api/reader/vote/route.ts +9 -0
  102. package/bin/papyrus-infra.mjs +20 -1
  103. package/components/editorial-checks-admin-panel.tsx +36 -11
  104. package/components/moderation-panel.tsx +248 -0
  105. package/components/monitoring-overview-view.tsx +241 -0
  106. package/components/monitoring-panel.tsx +40 -0
  107. package/components/newsletter-panel.tsx +474 -0
  108. package/components/newsletter-sections.tsx +163 -0
  109. package/components/newsroom-article-editor.tsx +2 -4
  110. package/components/newsroom-curation-bootstrap.tsx +45 -0
  111. package/components/newsroom-organizer.css +31 -0
  112. package/components/newsroom-references-view.tsx +14 -14
  113. package/components/publication-cyclotron-panel.tsx +15 -10
  114. package/components/reference-add-entry.tsx +75 -0
  115. package/components/reference-curation-controls.tsx +31 -22
  116. package/components/reference-cyclotron-priming.tsx +58 -19
  117. package/components/reference-cyclotron-suggestion.tsx +1 -1
  118. package/components/reference-priming.tsx +14 -10
  119. package/components/reference-section-suggestion.tsx +1 -1
  120. package/components/topic-steering-workspace.tsx +64 -20
  121. package/components/use-editorial-checks.ts +12 -10
  122. package/infra/amplify-app-shell.js +90 -16
  123. package/infra/amplify-app-shell.ts +102 -18
  124. package/infra/index.js +1 -0
  125. package/infra/secure-string-parameter-handler.js +41 -0
  126. package/infra/secure-string-parameter-handler.ts +41 -0
  127. package/infra/secure-string-parameter.js +79 -0
  128. package/infra/secure-string-parameter.ts +91 -0
  129. package/infra/site-config.js +86 -6
  130. package/infra/site-config.ts +129 -6
  131. package/lib/category-repository.ts +11 -9
  132. package/lib/curation-routes.ts +345 -0
  133. package/lib/editorial-checks-backend.ts +1 -1
  134. package/lib/editorial-checks-client.ts +19 -1
  135. package/lib/editorial-checks-demo.ts +2 -2
  136. package/lib/editorial-checks-settings.ts +3 -0
  137. package/lib/editorial-checks.ts +8 -11
  138. package/lib/editorial-preview.ts +5 -5
  139. package/lib/monitoring-client.ts +29 -0
  140. package/lib/monitoring-overview-fixture.ts +51 -0
  141. package/lib/monitoring-overview.ts +145 -0
  142. package/lib/newsletter-api.ts +418 -0
  143. package/lib/newsletter-client.ts +46 -0
  144. package/lib/newsletter-fixture.ts +184 -0
  145. package/lib/newsletter-model.ts +202 -0
  146. package/lib/prospectus-session.ts +15 -14
  147. package/lib/prospectus-sign-in-routes.ts +84 -19
  148. package/lib/prospectus-site-identity.ts +24 -1
  149. package/lib/reader-api.ts +145 -0
  150. package/lib/reader-moderation.ts +220 -0
  151. package/lib/reader-retirement-route.ts +89 -0
  152. package/lib/reader-subject.md +29 -0
  153. package/lib/reader-subject.ts +53 -0
  154. package/lib/relevance-status.ts +2 -2
  155. package/lib/semantic-graph.ts +2 -0
  156. package/lib/site-brand-tenant.ts +11 -6
  157. package/lib/site-brand.ts +6 -3
  158. package/lib/site-origin.ts +67 -0
  159. package/middleware.ts +14 -3
  160. package/package.json +42 -3
  161. package/routes.manifest.json +215 -2
  162. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  163. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -69
  164. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -85
  165. package/amplify/plugins/editorial-checks/schema.js +0 -94
@@ -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>>) {
@@ -78,23 +78,29 @@ export function siteFilesContentHash(siteFiles) {
78
78
  function pipSpec() {
79
79
  return (process.env.PAPYRUS_PIP_SPEC ?? "").trim() || `papyrus-newsroom==${toPep440(papyrusVersion())}`;
80
80
  }
81
+ /** What the first pip install gets: `papyrus-newsroom` with its dependencies, plus the function's own requirements. Empty means skip it. */
82
+ export function firstPipRequirements(options) {
83
+ return [...(options.noDeps ? [] : [pipSpec()]), ...(options.requirements ?? [])];
84
+ }
81
85
  function bundleFromPip(outputDir, options) {
82
86
  const extra = (process.env.PAPYRUS_PIP_EXTRA_ARGS ?? "").trim().split(/\s+/).filter(Boolean);
83
87
  const python = (process.env.PAPYRUS_PIP_PYTHON ?? "python3").trim();
84
- execFileSync(python, [
85
- "-m", "pip", "install",
86
- "--target", outputDir,
87
- "--no-cache-dir",
88
- "--platform", "manylinux2014_aarch64",
89
- "--implementation", "cp",
90
- "--python-version", "3.12",
91
- "--only-binary=:all:",
92
- // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
93
- "--ignore-requires-python",
94
- ...extra,
95
- ...(options.noDeps ? [] : [pipSpec()]),
96
- ...(options.requirements ?? []),
97
- ], { stdio: "inherit" });
88
+ const requirements = firstPipRequirements(options);
89
+ if (requirements.length > 0) {
90
+ execFileSync(python, [
91
+ "-m", "pip", "install",
92
+ "--target", outputDir,
93
+ "--no-cache-dir",
94
+ "--platform", "manylinux2014_aarch64",
95
+ "--implementation", "cp",
96
+ "--python-version", "3.12",
97
+ "--only-binary=:all:",
98
+ // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
99
+ "--ignore-requires-python",
100
+ ...extra,
101
+ ...requirements,
102
+ ], { stdio: "inherit" });
103
+ }
98
104
  if (options.noDeps) {
99
105
  execFileSync(python, [
100
106
  "-m", "pip", "install",
@@ -102,27 +102,34 @@ function pipSpec(): string {
102
102
  return (process.env.PAPYRUS_PIP_SPEC ?? "").trim() || `papyrus-newsroom==${toPep440(papyrusVersion())}`;
103
103
  }
104
104
 
105
+ /** What the first pip install gets: `papyrus-newsroom` with its dependencies, plus the function's own requirements. Empty means skip it. */
106
+ export function firstPipRequirements(options: Pick<PythonBundleOptions, "noDeps" | "requirements">): string[] {
107
+ return [...(options.noDeps ? [] : [pipSpec()]), ...(options.requirements ?? [])];
108
+ }
109
+
105
110
  function bundleFromPip(outputDir: string, options: PythonBundleOptions): void {
106
111
  const extra = (process.env.PAPYRUS_PIP_EXTRA_ARGS ?? "").trim().split(/\s+/).filter(Boolean);
107
112
  const python = (process.env.PAPYRUS_PIP_PYTHON ?? "python3").trim();
108
- execFileSync(
109
- python,
110
- [
111
- "-m", "pip", "install",
112
- "--target", outputDir,
113
- "--no-cache-dir",
114
- "--platform", "manylinux2014_aarch64",
115
- "--implementation", "cp",
116
- "--python-version", "3.12",
117
- "--only-binary=:all:",
118
- // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
119
- "--ignore-requires-python",
120
- ...extra,
121
- ...(options.noDeps ? [] : [pipSpec()]),
122
- ...(options.requirements ?? []),
123
- ],
124
- { stdio: "inherit" },
125
- );
113
+ const requirements = firstPipRequirements(options);
114
+ if (requirements.length > 0) {
115
+ execFileSync(
116
+ python,
117
+ [
118
+ "-m", "pip", "install",
119
+ "--target", outputDir,
120
+ "--no-cache-dir",
121
+ "--platform", "manylinux2014_aarch64",
122
+ "--implementation", "cp",
123
+ "--python-version", "3.12",
124
+ "--only-binary=:all:",
125
+ // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
126
+ "--ignore-requires-python",
127
+ ...extra,
128
+ ...requirements,
129
+ ],
130
+ { stdio: "inherit" },
131
+ );
132
+ }
126
133
  if (options.noDeps) {
127
134
  execFileSync(
128
135
  python,
@@ -0,0 +1,245 @@
1
+ export const NEWSLETTER_SITE_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
2
+ export const NEWSLETTER_LIST_ID = /^[a-z][a-z0-9-]{0,39}$/;
3
+ export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
4
+ const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
5
+ const LOCAL_PART = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/;
6
+ const SES_NAME = /^[A-Za-z0-9_-]{1,64}$/;
7
+ const ISSUE_TYPE = /^[a-z][a-z0-9_]{0,59}$/;
8
+ /** One path segment: it is the Item section of the archive and the `urls.sections` key of the site's projection.json. */
9
+ const ARCHIVE_PATH = /^\/[a-z0-9][a-z0-9_-]*\/$/;
10
+ const SCHEDULER_CRON = /^cron\(\S+ \S+ \S+ \S+ \S+ \S+\)$/;
11
+ const UNSAFE_CSS = /[<>{};"\\`]|url\(|expression\(|@import/i;
12
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
13
+ export const ITEM_URL_STYLES = ["html", "directory", "clean"];
14
+ const THEME_STYLE_KEYS = [
15
+ "surface", "muted", "fontFamily", "headerBand", "headerText", "buttonFill", "buttonText", "buttonBorder", "footerBand",
16
+ "footerText", "labelFont",
17
+ ];
18
+ const THEME_KEYS = [
19
+ "accent", "text", "background", ...THEME_STYLE_KEYS, "logoUrl", "wordmarkImageUrl", "wordmarkAlt", "wordmarkWidth", "wordmarkHeight",
20
+ "footerWordmarkWidth",
21
+ ];
22
+ const KNOWN_KEYS = new Set([
23
+ "name", "siteId", "siteOrigin", "basePath", "computeRoleArn", "sendingDomain", "fromAddress", "fromName", "instantAlerts", "theme", "lists",
24
+ "configurationSet", "eventTopicName", "itemUrlStyle",
25
+ ]);
26
+ const LIST_KEYS = {
27
+ roundup: ["id", "name", "description", "kind", "schedule", "issueType", "archivePath"],
28
+ alert: ["id", "name", "description", "kind", "rule", "approval"],
29
+ };
30
+ function fail(message) {
31
+ throw new Error(`newsletter plugin ${message}`);
32
+ }
33
+ function record(raw, label, known) {
34
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
35
+ fail(`${label} must be an object`);
36
+ const unknown = Object.keys(raw).filter((key) => !known.includes(key));
37
+ if (unknown.length > 0)
38
+ fail(`${label} has unknown settings: ${unknown.join(", ")}`);
39
+ return raw;
40
+ }
41
+ function text(raw, label, maximum, options = {}) {
42
+ const value = raw === undefined ? options.fallback : raw;
43
+ if (value === undefined)
44
+ fail(`${label} is required`);
45
+ if (typeof value !== "string" || CONTROL_CHARACTERS.test(value) || value.trim() !== value) {
46
+ fail(`${label} must be text on one line with no leading or trailing spaces`);
47
+ }
48
+ if (value.length === 0 && !options.optional)
49
+ fail(`${label} is required`);
50
+ if (value.length > maximum)
51
+ fail(`${label} must be at most ${maximum} characters`);
52
+ return value;
53
+ }
54
+ function matching(raw, label, pattern, description, fallback) {
55
+ const value = raw === undefined ? fallback : raw;
56
+ if (typeof value !== "string" || !pattern.test(value))
57
+ fail(`${label} must be ${description}`);
58
+ return value;
59
+ }
60
+ function timeZone(raw, label) {
61
+ const value = text(raw, label, 64);
62
+ try {
63
+ new Intl.DateTimeFormat("en-US", { timeZone: value });
64
+ }
65
+ catch {
66
+ fail(`${label} must be an IANA time zone such as America/New_York`);
67
+ }
68
+ return value;
69
+ }
70
+ function origin(raw) {
71
+ const value = text(raw, "siteOrigin", 200);
72
+ let parsed;
73
+ try {
74
+ parsed = new URL(value);
75
+ }
76
+ catch {
77
+ parsed = undefined;
78
+ }
79
+ if (!parsed || parsed.protocol !== "https:" || parsed.origin !== value)
80
+ fail("siteOrigin must be an https origin such as https://example.com with no path");
81
+ return value;
82
+ }
83
+ /** `urls.basePath` as projection.json writes it ("/posts" or "posts"): lowercase path segments, no dots, at most 3 deep. */
84
+ const BASE_PATH = /^[a-z0-9][a-z0-9_-]*(\/[a-z0-9][a-z0-9_-]*){0,2}$/;
85
+ function basePath(raw) {
86
+ if (raw === undefined)
87
+ return "";
88
+ const value = typeof raw === "string" ? raw.replace(/^\/+|\/+$/g, "") : null;
89
+ if (value === null || (value !== "" && !BASE_PATH.test(value)))
90
+ fail('basePath must be the site\'s urls.basePath, a lowercase path such as "/posts"');
91
+ return value;
92
+ }
93
+ function stringList(raw, label) {
94
+ if (raw === undefined)
95
+ return undefined;
96
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > 50)
97
+ fail(`${label} must be a list of 1 to 50 names`);
98
+ return [...new Set(raw.map((entry, index) => text(entry, `${label}[${index}]`, 80)))];
99
+ }
100
+ function itemUrlStyle(raw) {
101
+ if (raw === undefined)
102
+ return "html";
103
+ if (typeof raw !== "string" || !ITEM_URL_STYLES.includes(raw))
104
+ fail(`itemUrlStyle must be one of ${ITEM_URL_STYLES.join(", ")}`);
105
+ return raw;
106
+ }
107
+ function resolveTheme(raw) {
108
+ const theme = record(raw, "theme", THEME_KEYS);
109
+ const resolved = {
110
+ accent: text(theme.accent, "theme.accent", 80),
111
+ text: text(theme.text, "theme.text", 80),
112
+ background: text(theme.background, "theme.background", 80),
113
+ };
114
+ for (const key of THEME_STYLE_KEYS) {
115
+ if (theme[key] !== undefined)
116
+ resolved[key] = text(theme[key], `theme.${key}`, 200);
117
+ }
118
+ for (const [key, value] of Object.entries(resolved)) {
119
+ if (typeof value === "string" && UNSAFE_CSS.test(value))
120
+ fail(`theme.${key} contains characters that are not allowed in a style value`);
121
+ }
122
+ if (theme.logoUrl !== undefined) {
123
+ const logoUrl = text(theme.logoUrl, "theme.logoUrl", 500);
124
+ if (!/^https:\/\//.test(logoUrl))
125
+ fail("theme.logoUrl must be an https URL");
126
+ resolved.logoUrl = logoUrl;
127
+ }
128
+ if (theme.wordmarkImageUrl !== undefined) {
129
+ const url = text(theme.wordmarkImageUrl, "theme.wordmarkImageUrl", 500);
130
+ if (!/^https:\/\/[^\s"'<>\\`]+$/.test(url))
131
+ fail("theme.wordmarkImageUrl must be an absolute https URL");
132
+ resolved.wordmarkImageUrl = url;
133
+ for (const key of ["wordmarkWidth", "wordmarkHeight"]) {
134
+ const value = theme[key];
135
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 2000) {
136
+ fail(`theme.${key} is required with theme.wordmarkImageUrl and must be a whole number of pixels from 1 to 2000`);
137
+ }
138
+ resolved[key] = value;
139
+ }
140
+ }
141
+ else if (theme.wordmarkWidth !== undefined || theme.wordmarkHeight !== undefined) {
142
+ fail("theme.wordmarkWidth and theme.wordmarkHeight need theme.wordmarkImageUrl");
143
+ }
144
+ if (theme.footerWordmarkWidth !== undefined) {
145
+ if (theme.wordmarkImageUrl === undefined)
146
+ fail("theme.footerWordmarkWidth needs theme.wordmarkImageUrl");
147
+ const width = theme.footerWordmarkWidth;
148
+ if (typeof width !== "number" || !Number.isInteger(width) || width < 1 || width > 400) {
149
+ fail("theme.footerWordmarkWidth must be a whole number of pixels from 1 to 400");
150
+ }
151
+ resolved.footerWordmarkWidth = width;
152
+ }
153
+ if (theme.wordmarkAlt !== undefined) {
154
+ const alt = text(theme.wordmarkAlt, "theme.wordmarkAlt", 200);
155
+ if (/[\u0000-\u001f\u007f]/.test(alt))
156
+ fail("theme.wordmarkAlt must not contain control characters");
157
+ resolved.wordmarkAlt = alt;
158
+ }
159
+ return resolved;
160
+ }
161
+ function resolveRule(raw, label) {
162
+ if (raw === undefined)
163
+ return {};
164
+ const rule = record(raw, label, ["sections", "tags", "types"]);
165
+ return {
166
+ ...(rule.sections === undefined ? {} : { sections: stringList(rule.sections, `${label}.sections`) }),
167
+ ...(rule.tags === undefined ? {} : { tags: stringList(rule.tags, `${label}.tags`) }),
168
+ ...(rule.types === undefined ? {} : { types: stringList(rule.types, `${label}.types`) }),
169
+ };
170
+ }
171
+ function resolveList(raw, index, instantAlerts) {
172
+ const label = `lists[${index}]`;
173
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
174
+ fail(`${label} must be an object`);
175
+ const kind = raw.kind;
176
+ if (kind !== "roundup" && kind !== "alert")
177
+ fail(`${label}.kind must be "roundup" or "alert"`);
178
+ const list = record(raw, label, LIST_KEYS[kind]);
179
+ const base = {
180
+ id: matching(list.id, `${label}.id`, NEWSLETTER_LIST_ID, "lowercase letters, digits and hyphens, starting with a letter (at most 40 characters)"),
181
+ name: text(list.name, `${label}.name`, 80),
182
+ description: text(list.description, `${label}.description`, 300, { optional: true, fallback: "" }),
183
+ };
184
+ if (kind === "roundup") {
185
+ const schedule = record(list.schedule, `${label}.schedule`, ["draft", "timezone"]);
186
+ return {
187
+ ...base,
188
+ kind,
189
+ schedule: {
190
+ draft: matching(schedule.draft, `${label}.schedule.draft`, SCHEDULER_CRON, 'an EventBridge Scheduler cron such as "cron(0 13 ? * FRI *)"'),
191
+ timezone: timeZone(schedule.timezone, `${label}.schedule.timezone`),
192
+ },
193
+ issueType: matching(list.issueType, `${label}.issueType`, ISSUE_TYPE, "a lowercase item type name", "newsletter_issue"),
194
+ archivePath: matching(list.archivePath, `${label}.archivePath`, ARCHIVE_PATH, 'a path that starts and ends with "/", such as /roundup/'),
195
+ };
196
+ }
197
+ const approval = list.approval === undefined ? "admin" : list.approval;
198
+ if (approval !== "admin" && approval !== "none")
199
+ fail(`${label}.approval must be "admin" or "none"`);
200
+ if (approval === "none" && !instantAlerts) {
201
+ fail(`${label}.approval "none" sends alerts without an admin approving them: set instantAlerts: true on the plugin first`);
202
+ }
203
+ return { ...base, kind, rule: resolveRule(list.rule, `${label}.rule`), approval };
204
+ }
205
+ export function resolveNewsletterConfig(request) {
206
+ const raw = record(typeof request === "string" ? { name: request } : request, "settings", [...KNOWN_KEYS]);
207
+ const siteId = matching(raw.siteId, "siteId", NEWSLETTER_SITE_ID, "a lowercase site id (letters, digits, hyphens), the SiteId of the sending stack");
208
+ const computeRoleArn = raw.computeRoleArn;
209
+ if (typeof computeRoleArn !== "string" || !COMPUTE_ROLE_ARN.test(computeRoleArn)) {
210
+ fail("computeRoleArn must be the IAM role ARN of the CMS app's compute role (the CmsComputeRoleArn output)");
211
+ }
212
+ const sendingDomain = matching(raw.sendingDomain, "sendingDomain", HOSTNAME, "a lowercase domain name such as news.example.com");
213
+ const fromAddress = text(raw.fromAddress, "fromAddress", 254);
214
+ const [local, domain, ...extra] = fromAddress.split("@");
215
+ if (extra.length > 0 || !local || local.length > 64 || !LOCAL_PART.test(local) || domain !== sendingDomain) {
216
+ fail(`fromAddress must be an address on ${sendingDomain} (lowercase, such as newsletter@${sendingDomain})`);
217
+ }
218
+ if (raw.instantAlerts !== undefined && typeof raw.instantAlerts !== "boolean")
219
+ fail("instantAlerts must be a boolean");
220
+ const instantAlerts = raw.instantAlerts === true;
221
+ if (!Array.isArray(raw.lists) || raw.lists.length === 0 || raw.lists.length > 20)
222
+ fail("lists must be a list of 1 to 20 lists");
223
+ const lists = raw.lists.map((entry, index) => resolveList(entry, index, instantAlerts));
224
+ const duplicates = lists.map((list) => list.id).filter((id, index, ids) => ids.indexOf(id) !== index);
225
+ if (duplicates.length > 0)
226
+ fail(`lists has duplicate ids: ${[...new Set(duplicates)].join(", ")}`);
227
+ const roundups = lists.filter((list) => list.kind === "roundup");
228
+ if (new Set(roundups.map((list) => list.archivePath)).size !== roundups.length)
229
+ fail("roundup lists need different archivePath values");
230
+ return {
231
+ siteId,
232
+ siteOrigin: origin(raw.siteOrigin),
233
+ basePath: basePath(raw.basePath),
234
+ computeRoleArn,
235
+ sendingDomain,
236
+ fromAddress,
237
+ fromName: text(raw.fromName, "fromName", 80),
238
+ instantAlerts,
239
+ theme: resolveTheme(raw.theme),
240
+ lists,
241
+ itemUrlStyle: itemUrlStyle(raw.itemUrlStyle),
242
+ configurationSet: matching(raw.configurationSet, "configurationSet", SES_NAME, "an SES configuration set name", `papyrus-${siteId}`),
243
+ eventTopicName: matching(raw.eventTopicName, "eventTopicName", SES_NAME, "an SNS topic name", `papyrus-newsletter-events-${siteId}`),
244
+ };
245
+ }