@anthusai/papyrus 5.20.0 → 5.22.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.
- package/amplify/data/schema.js +11 -25
- package/amplify/data/schema.ts +13 -26
- package/amplify/functions/content-actions/handler.py +17 -2
- package/amplify/plugins/newsletter/config.js +195 -0
- package/amplify/plugins/newsletter/config.ts +240 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +260 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +308 -0
- package/amplify/plugins/newsletter/schema.js +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +52 -1
- package/amplify/plugins/reader-steering/config.ts +91 -1
- package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
- package/amplify/plugins/reader-steering/function/handler.py +829 -10
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/resource.js +1 -1
- package/amplify/plugins/reader-steering/function/resource.ts +1 -1
- package/amplify/plugins/reader-steering/papyrus.plugin.js +40 -4
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +43 -4
- package/amplify/plugins/reader-steering/schema.js +101 -1
- package/amplify/plugins/reader-steering/schema.ts +101 -1
- package/amplify/plugins/registry.js +2 -0
- package/amplify/plugins/registry.ts +2 -0
- package/amplify/plugins/types.ts +2 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/components/moderation-panel.tsx +248 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-organizer.css +14 -0
- package/components/topic-steering-workspace.tsx +42 -5
- package/lib/newsletter-api.ts +391 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsletter-pages.ts +264 -0
- package/lib/reader-api.ts +7 -5
- package/lib/reader-moderation.ts +220 -0
- package/package.json +19 -1
- package/routes.manifest.json +103 -1
package/amplify/data/schema.js
CHANGED
|
@@ -2033,19 +2033,9 @@ export function baseModelNames(excludedGroups = []) {
|
|
|
2033
2033
|
return Object.keys(buildBaseDefinitions(!excludedGroups.includes("editions")));
|
|
2034
2034
|
}
|
|
2035
2035
|
const baseDefinitions = buildBaseDefinitions(true);
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
allow.resource(contentActions),
|
|
2040
|
-
allow.resource(manageUserRole),
|
|
2041
|
-
allow.resource(modelAttachmentUpload),
|
|
2042
|
-
allow.resource(newsroomSummary),
|
|
2043
|
-
allow.resource(procedureAction),
|
|
2044
|
-
allow.resource(publicForms).to(["mutate"]),
|
|
2045
|
-
]);
|
|
2046
|
-
/** The deployed schema of a site that enables plugins: the base definitions plus what each plugin contributes (docs/plugins.md). */
|
|
2047
|
-
export function schemaWithPlugins(pluginDefinitions) {
|
|
2048
|
-
return a.schema({ ...baseDefinitions, ...pluginDefinitions }).authorization((allow) => [
|
|
2036
|
+
/** Schema-level function grants shared by every deployed schema, with or without plugins, so none can drop one. */
|
|
2037
|
+
function functionGrants(allow) {
|
|
2038
|
+
return [
|
|
2049
2039
|
allow.resource(assignmentAction).to(["query", "mutate"]),
|
|
2050
2040
|
allow.resource(categoryAction),
|
|
2051
2041
|
allow.resource(contentActions),
|
|
@@ -2053,20 +2043,16 @@ export function schemaWithPlugins(pluginDefinitions) {
|
|
|
2053
2043
|
allow.resource(modelAttachmentUpload),
|
|
2054
2044
|
allow.resource(newsroomSummary),
|
|
2055
2045
|
allow.resource(procedureAction),
|
|
2056
|
-
|
|
2046
|
+
allow.resource(publicForms).to(["mutate"]),
|
|
2047
|
+
];
|
|
2048
|
+
}
|
|
2049
|
+
export const schema = a.schema(baseDefinitions).authorization(functionGrants);
|
|
2050
|
+
/** The deployed schema of a site that enables plugins: the base definitions plus what each plugin contributes (docs/plugins.md). */
|
|
2051
|
+
export function schemaWithPlugins(pluginDefinitions) {
|
|
2052
|
+
return a.schema({ ...baseDefinitions, ...pluginDefinitions }).authorization(functionGrants);
|
|
2057
2053
|
}
|
|
2058
2054
|
/** The schema of a site that leaves optional model groups out of its first deploy. */
|
|
2059
2055
|
export function schemaExcludingGroups(excludedGroups, pluginDefinitions = {}) {
|
|
2060
2056
|
const definitions = buildBaseDefinitions(!excludedGroups.includes("editions"));
|
|
2061
|
-
|
|
2062
|
-
return a.schema({ ...definitions, ...pluginDefinitions }).authorization((allow) => [
|
|
2063
|
-
allow.resource(assignmentAction).to(["query", "mutate"]),
|
|
2064
|
-
allow.resource(categoryAction),
|
|
2065
|
-
allow.resource(contentActions),
|
|
2066
|
-
allow.resource(manageUserRole),
|
|
2067
|
-
allow.resource(modelAttachmentUpload),
|
|
2068
|
-
allow.resource(newsroomSummary),
|
|
2069
|
-
allow.resource(procedureAction),
|
|
2070
|
-
...(hasPlugins ? [] : [allow.resource(publicForms).to(["mutate"])]),
|
|
2071
|
-
]);
|
|
2057
|
+
return a.schema({ ...definitions, ...pluginDefinitions }).authorization(functionGrants);
|
|
2072
2058
|
}
|
package/amplify/data/schema.ts
CHANGED
|
@@ -2159,20 +2159,9 @@ export function baseModelNames(excludedGroups: readonly OptionalModelGroup[] = [
|
|
|
2159
2159
|
|
|
2160
2160
|
const baseDefinitions = buildBaseDefinitions(true);
|
|
2161
2161
|
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
allow.resource(contentActions),
|
|
2166
|
-
allow.resource(manageUserRole),
|
|
2167
|
-
allow.resource(modelAttachmentUpload),
|
|
2168
|
-
allow.resource(newsroomSummary),
|
|
2169
|
-
allow.resource(procedureAction),
|
|
2170
|
-
allow.resource(publicForms).to(["mutate"]),
|
|
2171
|
-
]);
|
|
2172
|
-
|
|
2173
|
-
/** The deployed schema of a site that enables plugins: the base definitions plus what each plugin contributes (docs/plugins.md). */
|
|
2174
|
-
export function schemaWithPlugins(pluginDefinitions: Record<string, any>) {
|
|
2175
|
-
return a.schema({ ...baseDefinitions, ...pluginDefinitions } as typeof baseDefinitions).authorization((allow) => [
|
|
2162
|
+
/** Schema-level function grants shared by every deployed schema, with or without plugins, so none can drop one. */
|
|
2163
|
+
function functionGrants(allow: any) {
|
|
2164
|
+
return [
|
|
2176
2165
|
allow.resource(assignmentAction).to(["query", "mutate"]),
|
|
2177
2166
|
allow.resource(categoryAction),
|
|
2178
2167
|
allow.resource(contentActions),
|
|
@@ -2180,23 +2169,21 @@ export function schemaWithPlugins(pluginDefinitions: Record<string, any>) {
|
|
|
2180
2169
|
allow.resource(modelAttachmentUpload),
|
|
2181
2170
|
allow.resource(newsroomSummary),
|
|
2182
2171
|
allow.resource(procedureAction),
|
|
2183
|
-
|
|
2172
|
+
allow.resource(publicForms).to(["mutate"]),
|
|
2173
|
+
];
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
export const schema = a.schema(baseDefinitions).authorization(functionGrants);
|
|
2177
|
+
|
|
2178
|
+
/** The deployed schema of a site that enables plugins: the base definitions plus what each plugin contributes (docs/plugins.md). */
|
|
2179
|
+
export function schemaWithPlugins(pluginDefinitions: Record<string, any>) {
|
|
2180
|
+
return a.schema({ ...baseDefinitions, ...pluginDefinitions } as typeof baseDefinitions).authorization(functionGrants);
|
|
2184
2181
|
}
|
|
2185
2182
|
|
|
2186
2183
|
/** The schema of a site that leaves optional model groups out of its first deploy. */
|
|
2187
2184
|
export function schemaExcludingGroups(excludedGroups: readonly OptionalModelGroup[], pluginDefinitions: Record<string, any> = {}) {
|
|
2188
2185
|
const definitions = buildBaseDefinitions(!excludedGroups.includes("editions")) as unknown as typeof baseDefinitions;
|
|
2189
|
-
|
|
2190
|
-
return a.schema({ ...definitions, ...pluginDefinitions } as typeof baseDefinitions).authorization((allow) => [
|
|
2191
|
-
allow.resource(assignmentAction).to(["query", "mutate"]),
|
|
2192
|
-
allow.resource(categoryAction),
|
|
2193
|
-
allow.resource(contentActions),
|
|
2194
|
-
allow.resource(manageUserRole),
|
|
2195
|
-
allow.resource(modelAttachmentUpload),
|
|
2196
|
-
allow.resource(newsroomSummary),
|
|
2197
|
-
allow.resource(procedureAction),
|
|
2198
|
-
...(hasPlugins ? [] : [allow.resource(publicForms).to(["mutate"])]),
|
|
2199
|
-
]);
|
|
2186
|
+
return a.schema({ ...definitions, ...pluginDefinitions } as typeof baseDefinitions).authorization(functionGrants);
|
|
2200
2187
|
}
|
|
2201
2188
|
|
|
2202
2189
|
export type Schema = ClientSchema<typeof schema>;
|
|
@@ -27,6 +27,7 @@ from papyrus_content.story_reporting import save_story_version
|
|
|
27
27
|
from papyrus_content.publication import ensure_publication_from_environment
|
|
28
28
|
from papyrus_content.editorial_checks import GraphQLEditorialStore, ensure_default_policies
|
|
29
29
|
from papyrus_content.reference_intake import IntakeError, intake_references
|
|
30
|
+
from papyrus_content.publish_alerts import PUBLISHED_TASK_KEY, announce_published
|
|
30
31
|
from papyrus_content.publishing import (
|
|
31
32
|
ItemFields,
|
|
32
33
|
PublishError,
|
|
@@ -119,11 +120,17 @@ def projection_context(client):
|
|
|
119
120
|
return context
|
|
120
121
|
|
|
121
122
|
|
|
122
|
-
def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
|
|
123
|
+
def project_item_action(client, item_id: str, *, unpublished: bool, published_item_id: str | None = None) -> dict:
|
|
124
|
+
"""Project one item. ``published_item_id`` is passed only for a real publish: the alert is announced once the page
|
|
125
|
+
is live, here when the projection succeeds, or by the scheduled retry (which carries the same marker) when it did not."""
|
|
123
126
|
if not projection_configured():
|
|
127
|
+
if published_item_id:
|
|
128
|
+
announce_published(item_id, published_item_id)
|
|
124
129
|
return {}
|
|
125
130
|
liveness = {} if unpublished else {"live": False}
|
|
126
131
|
task = {"task": "item", "itemId": item_id, "unpublished": unpublished}
|
|
132
|
+
if published_item_id:
|
|
133
|
+
task[PUBLISHED_TASK_KEY] = {"publishedItemId": published_item_id}
|
|
127
134
|
try:
|
|
128
135
|
report = project(projection_context(client), item_id, unpublished=unpublished)
|
|
129
136
|
except Exception as error: # noqa: BLE001 - a projection failure never fails the save
|
|
@@ -145,6 +152,8 @@ def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
|
|
|
145
152
|
schedule_unpurged_invalidation(report.unpurged)
|
|
146
153
|
if report.follow_up_item_pages:
|
|
147
154
|
schedule_projection_task({"task": "item-pages", "exceptItemId": item_id}, "follow-up")
|
|
155
|
+
if report.ok and published_item_id:
|
|
156
|
+
announce_published(item_id, published_item_id)
|
|
148
157
|
return response
|
|
149
158
|
|
|
150
159
|
|
|
@@ -201,6 +210,10 @@ def run_projection_task(task: dict) -> dict:
|
|
|
201
210
|
schedule_unpurged_invalidation(report.unpurged)
|
|
202
211
|
if not report.ok:
|
|
203
212
|
raise ProjectionTaskFailed("; ".join(report.errors))
|
|
213
|
+
published = task.get(PUBLISHED_TASK_KEY)
|
|
214
|
+
if task.get("task") == "item" and not task.get("unpublished") and isinstance(published, dict):
|
|
215
|
+
# A real publish (the CLI marks it, as does the newsroom's retry): the page is live, so tell the newsletter.
|
|
216
|
+
announce_published(task["itemId"], published.get("publishedItemId"))
|
|
204
217
|
return {"ok": True, "paths": [*report.written, *report.deleted]}
|
|
205
218
|
|
|
206
219
|
|
|
@@ -286,7 +299,9 @@ def publish_item_action(arguments: dict, actor: str) -> dict:
|
|
|
286
299
|
}
|
|
287
300
|
if result.changed:
|
|
288
301
|
response.update(site_triggers_for(client, arguments["id"]))
|
|
289
|
-
response.update(
|
|
302
|
+
response.update(
|
|
303
|
+
project_item_action(client, arguments["id"], unpublished=False, published_item_id=result.published_item_id if result.changed else None)
|
|
304
|
+
)
|
|
290
305
|
return response
|
|
291
306
|
|
|
292
307
|
|
|
@@ -0,0 +1,195 @@
|
|
|
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 KNOWN_KEYS = new Set([
|
|
15
|
+
"name", "siteId", "siteOrigin", "computeRoleArn", "sendingDomain", "fromAddress", "fromName", "instantAlerts", "theme", "lists",
|
|
16
|
+
"configurationSet", "eventTopicName", "itemUrlStyle",
|
|
17
|
+
]);
|
|
18
|
+
const LIST_KEYS = {
|
|
19
|
+
roundup: ["id", "name", "description", "kind", "schedule", "issueType", "archivePath"],
|
|
20
|
+
alert: ["id", "name", "description", "kind", "rule", "approval"],
|
|
21
|
+
};
|
|
22
|
+
function fail(message) {
|
|
23
|
+
throw new Error(`newsletter plugin ${message}`);
|
|
24
|
+
}
|
|
25
|
+
function record(raw, label, known) {
|
|
26
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
27
|
+
fail(`${label} must be an object`);
|
|
28
|
+
const unknown = Object.keys(raw).filter((key) => !known.includes(key));
|
|
29
|
+
if (unknown.length > 0)
|
|
30
|
+
fail(`${label} has unknown settings: ${unknown.join(", ")}`);
|
|
31
|
+
return raw;
|
|
32
|
+
}
|
|
33
|
+
function text(raw, label, maximum, options = {}) {
|
|
34
|
+
const value = raw === undefined ? options.fallback : raw;
|
|
35
|
+
if (value === undefined)
|
|
36
|
+
fail(`${label} is required`);
|
|
37
|
+
if (typeof value !== "string" || CONTROL_CHARACTERS.test(value) || value.trim() !== value) {
|
|
38
|
+
fail(`${label} must be text on one line with no leading or trailing spaces`);
|
|
39
|
+
}
|
|
40
|
+
if (value.length === 0 && !options.optional)
|
|
41
|
+
fail(`${label} is required`);
|
|
42
|
+
if (value.length > maximum)
|
|
43
|
+
fail(`${label} must be at most ${maximum} characters`);
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function matching(raw, label, pattern, description, fallback) {
|
|
47
|
+
const value = raw === undefined ? fallback : raw;
|
|
48
|
+
if (typeof value !== "string" || !pattern.test(value))
|
|
49
|
+
fail(`${label} must be ${description}`);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
function timeZone(raw, label) {
|
|
53
|
+
const value = text(raw, label, 64);
|
|
54
|
+
try {
|
|
55
|
+
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
fail(`${label} must be an IANA time zone such as America/New_York`);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
function origin(raw) {
|
|
63
|
+
const value = text(raw, "siteOrigin", 200);
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = new URL(value);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
parsed = undefined;
|
|
70
|
+
}
|
|
71
|
+
if (!parsed || parsed.protocol !== "https:" || parsed.origin !== value)
|
|
72
|
+
fail("siteOrigin must be an https origin such as https://example.com with no path");
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
function stringList(raw, label) {
|
|
76
|
+
if (raw === undefined)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.length > 50)
|
|
79
|
+
fail(`${label} must be a list of 1 to 50 names`);
|
|
80
|
+
return [...new Set(raw.map((entry, index) => text(entry, `${label}[${index}]`, 80)))];
|
|
81
|
+
}
|
|
82
|
+
function itemUrlStyle(raw) {
|
|
83
|
+
if (raw === undefined)
|
|
84
|
+
return "html";
|
|
85
|
+
if (typeof raw !== "string" || !ITEM_URL_STYLES.includes(raw))
|
|
86
|
+
fail(`itemUrlStyle must be one of ${ITEM_URL_STYLES.join(", ")}`);
|
|
87
|
+
return raw;
|
|
88
|
+
}
|
|
89
|
+
function resolveTheme(raw) {
|
|
90
|
+
const theme = record(raw, "theme", ["accent", "text", "background", "surface", "muted", "fontFamily", "logoUrl"]);
|
|
91
|
+
const resolved = {
|
|
92
|
+
accent: text(theme.accent, "theme.accent", 80),
|
|
93
|
+
text: text(theme.text, "theme.text", 80),
|
|
94
|
+
background: text(theme.background, "theme.background", 80),
|
|
95
|
+
};
|
|
96
|
+
for (const key of ["surface", "muted", "fontFamily"]) {
|
|
97
|
+
if (theme[key] !== undefined)
|
|
98
|
+
resolved[key] = text(theme[key], `theme.${key}`, 200);
|
|
99
|
+
}
|
|
100
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
101
|
+
if (UNSAFE_CSS.test(value))
|
|
102
|
+
fail(`theme.${key} contains characters that are not allowed in a style value`);
|
|
103
|
+
}
|
|
104
|
+
if (theme.logoUrl !== undefined) {
|
|
105
|
+
const logoUrl = text(theme.logoUrl, "theme.logoUrl", 500);
|
|
106
|
+
if (!/^https:\/\//.test(logoUrl))
|
|
107
|
+
fail("theme.logoUrl must be an https URL");
|
|
108
|
+
resolved.logoUrl = logoUrl;
|
|
109
|
+
}
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
function resolveRule(raw, label) {
|
|
113
|
+
if (raw === undefined)
|
|
114
|
+
return {};
|
|
115
|
+
const rule = record(raw, label, ["sections", "tags", "types"]);
|
|
116
|
+
return {
|
|
117
|
+
...(rule.sections === undefined ? {} : { sections: stringList(rule.sections, `${label}.sections`) }),
|
|
118
|
+
...(rule.tags === undefined ? {} : { tags: stringList(rule.tags, `${label}.tags`) }),
|
|
119
|
+
...(rule.types === undefined ? {} : { types: stringList(rule.types, `${label}.types`) }),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function resolveList(raw, index, instantAlerts) {
|
|
123
|
+
const label = `lists[${index}]`;
|
|
124
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
125
|
+
fail(`${label} must be an object`);
|
|
126
|
+
const kind = raw.kind;
|
|
127
|
+
if (kind !== "roundup" && kind !== "alert")
|
|
128
|
+
fail(`${label}.kind must be "roundup" or "alert"`);
|
|
129
|
+
const list = record(raw, label, LIST_KEYS[kind]);
|
|
130
|
+
const base = {
|
|
131
|
+
id: matching(list.id, `${label}.id`, NEWSLETTER_LIST_ID, "lowercase letters, digits and hyphens, starting with a letter (at most 40 characters)"),
|
|
132
|
+
name: text(list.name, `${label}.name`, 80),
|
|
133
|
+
description: text(list.description, `${label}.description`, 300, { optional: true, fallback: "" }),
|
|
134
|
+
};
|
|
135
|
+
if (kind === "roundup") {
|
|
136
|
+
const schedule = record(list.schedule, `${label}.schedule`, ["draft", "timezone"]);
|
|
137
|
+
return {
|
|
138
|
+
...base,
|
|
139
|
+
kind,
|
|
140
|
+
schedule: {
|
|
141
|
+
draft: matching(schedule.draft, `${label}.schedule.draft`, SCHEDULER_CRON, 'an EventBridge Scheduler cron such as "cron(0 13 ? * FRI *)"'),
|
|
142
|
+
timezone: timeZone(schedule.timezone, `${label}.schedule.timezone`),
|
|
143
|
+
},
|
|
144
|
+
issueType: matching(list.issueType, `${label}.issueType`, ISSUE_TYPE, "a lowercase item type name", "newsletter_issue"),
|
|
145
|
+
archivePath: matching(list.archivePath, `${label}.archivePath`, ARCHIVE_PATH, 'a path that starts and ends with "/", such as /roundup/'),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const approval = list.approval === undefined ? "admin" : list.approval;
|
|
149
|
+
if (approval !== "admin" && approval !== "none")
|
|
150
|
+
fail(`${label}.approval must be "admin" or "none"`);
|
|
151
|
+
if (approval === "none" && !instantAlerts) {
|
|
152
|
+
fail(`${label}.approval "none" sends alerts without an admin approving them: set instantAlerts: true on the plugin first`);
|
|
153
|
+
}
|
|
154
|
+
return { ...base, kind, rule: resolveRule(list.rule, `${label}.rule`), approval };
|
|
155
|
+
}
|
|
156
|
+
export function resolveNewsletterConfig(request) {
|
|
157
|
+
const raw = record(typeof request === "string" ? { name: request } : request, "settings", [...KNOWN_KEYS]);
|
|
158
|
+
const siteId = matching(raw.siteId, "siteId", NEWSLETTER_SITE_ID, "a lowercase site id (letters, digits, hyphens), the SiteId of the sending stack");
|
|
159
|
+
const computeRoleArn = raw.computeRoleArn;
|
|
160
|
+
if (typeof computeRoleArn !== "string" || !COMPUTE_ROLE_ARN.test(computeRoleArn)) {
|
|
161
|
+
fail("computeRoleArn must be the IAM role ARN of the CMS app's compute role (the CmsComputeRoleArn output)");
|
|
162
|
+
}
|
|
163
|
+
const sendingDomain = matching(raw.sendingDomain, "sendingDomain", HOSTNAME, "a lowercase domain name such as news.example.com");
|
|
164
|
+
const fromAddress = text(raw.fromAddress, "fromAddress", 254);
|
|
165
|
+
const [local, domain, ...extra] = fromAddress.split("@");
|
|
166
|
+
if (extra.length > 0 || !local || local.length > 64 || !LOCAL_PART.test(local) || domain !== sendingDomain) {
|
|
167
|
+
fail(`fromAddress must be an address on ${sendingDomain} (lowercase, such as newsletter@${sendingDomain})`);
|
|
168
|
+
}
|
|
169
|
+
if (raw.instantAlerts !== undefined && typeof raw.instantAlerts !== "boolean")
|
|
170
|
+
fail("instantAlerts must be a boolean");
|
|
171
|
+
const instantAlerts = raw.instantAlerts === true;
|
|
172
|
+
if (!Array.isArray(raw.lists) || raw.lists.length === 0 || raw.lists.length > 20)
|
|
173
|
+
fail("lists must be a list of 1 to 20 lists");
|
|
174
|
+
const lists = raw.lists.map((entry, index) => resolveList(entry, index, instantAlerts));
|
|
175
|
+
const duplicates = lists.map((list) => list.id).filter((id, index, ids) => ids.indexOf(id) !== index);
|
|
176
|
+
if (duplicates.length > 0)
|
|
177
|
+
fail(`lists has duplicate ids: ${[...new Set(duplicates)].join(", ")}`);
|
|
178
|
+
const roundups = lists.filter((list) => list.kind === "roundup");
|
|
179
|
+
if (new Set(roundups.map((list) => list.archivePath)).size !== roundups.length)
|
|
180
|
+
fail("roundup lists need different archivePath values");
|
|
181
|
+
return {
|
|
182
|
+
siteId,
|
|
183
|
+
siteOrigin: origin(raw.siteOrigin),
|
|
184
|
+
computeRoleArn,
|
|
185
|
+
sendingDomain,
|
|
186
|
+
fromAddress,
|
|
187
|
+
fromName: text(raw.fromName, "fromName", 80),
|
|
188
|
+
instantAlerts,
|
|
189
|
+
theme: resolveTheme(raw.theme),
|
|
190
|
+
lists,
|
|
191
|
+
itemUrlStyle: itemUrlStyle(raw.itemUrlStyle),
|
|
192
|
+
configurationSet: matching(raw.configurationSet, "configurationSet", SES_NAME, "an SES configuration set name", `papyrus-${siteId}`),
|
|
193
|
+
eventTopicName: matching(raw.eventTopicName, "eventTopicName", SES_NAME, "an SNS topic name", `papyrus-newsletter-events-${siteId}`),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { PluginRequest } from "../types";
|
|
2
|
+
|
|
3
|
+
export const NEWSLETTER_SITE_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
4
|
+
export const NEWSLETTER_LIST_ID = /^[a-z][a-z0-9-]{0,39}$/;
|
|
5
|
+
export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
|
|
6
|
+
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])?)+$/;
|
|
7
|
+
const LOCAL_PART = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/;
|
|
8
|
+
const SES_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
9
|
+
const ISSUE_TYPE = /^[a-z][a-z0-9_]{0,59}$/;
|
|
10
|
+
/** One path segment: it is the Item section of the archive and the `urls.sections` key of the site's projection.json. */
|
|
11
|
+
const ARCHIVE_PATH = /^\/[a-z0-9][a-z0-9_-]*\/$/;
|
|
12
|
+
const SCHEDULER_CRON = /^cron\(\S+ \S+ \S+ \S+ \S+ \S+\)$/;
|
|
13
|
+
const UNSAFE_CSS = /[<>{};"\\`]|url\(|expression\(|@import/i;
|
|
14
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
|
15
|
+
|
|
16
|
+
export const ITEM_URL_STYLES = ["html", "directory", "clean"] as const;
|
|
17
|
+
export type ItemUrlStyle = (typeof ITEM_URL_STYLES)[number];
|
|
18
|
+
|
|
19
|
+
export type NewsletterTheme = {
|
|
20
|
+
accent: string;
|
|
21
|
+
text: string;
|
|
22
|
+
background: string;
|
|
23
|
+
surface?: string;
|
|
24
|
+
muted?: string;
|
|
25
|
+
fontFamily?: string;
|
|
26
|
+
logoUrl?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type NewsletterRule = { sections?: string[]; tags?: string[]; types?: string[] };
|
|
30
|
+
|
|
31
|
+
type ListBase = { id: string; name: string; description: string };
|
|
32
|
+
export type RoundupList = ListBase & {
|
|
33
|
+
kind: "roundup";
|
|
34
|
+
/** EventBridge Scheduler cron for assembling the draft, and the IANA time zone it runs in. */
|
|
35
|
+
schedule: { draft: string; timezone: string };
|
|
36
|
+
/** The CMS item type of an issue. */
|
|
37
|
+
issueType: string;
|
|
38
|
+
/** Where the sent issue is archived, such as `/roundup/`: one path segment, also the section the issue Items are filed under. */
|
|
39
|
+
archivePath: string;
|
|
40
|
+
};
|
|
41
|
+
export type AlertList = ListBase & {
|
|
42
|
+
kind: "alert";
|
|
43
|
+
/** Which published items raise an alert. Absent or empty: every published item (an admin still approves it). */
|
|
44
|
+
rule: NewsletterRule;
|
|
45
|
+
approval: "admin" | "none";
|
|
46
|
+
};
|
|
47
|
+
export type NewsletterList = RoundupList | AlertList;
|
|
48
|
+
|
|
49
|
+
export type NewsletterConfig = {
|
|
50
|
+
/** The site id: names the key parameter, the configuration set and the event topic. Must match the sending stack's SiteId. */
|
|
51
|
+
siteId: string;
|
|
52
|
+
/** The site's public https origin: confirmation links point at `<siteOrigin>/api/newsletter/confirm`. */
|
|
53
|
+
siteOrigin: string;
|
|
54
|
+
/** The IAM role of the CMS app's server runtime (the `CmsComputeRoleArn` output of the app-shell stack): the only caller of the function. */
|
|
55
|
+
computeRoleArn: string;
|
|
56
|
+
sendingDomain: string;
|
|
57
|
+
fromAddress: string;
|
|
58
|
+
fromName: string;
|
|
59
|
+
/** A site must set this to true before any list may send without approval. */
|
|
60
|
+
instantAlerts: boolean;
|
|
61
|
+
theme: NewsletterTheme;
|
|
62
|
+
lists: NewsletterList[];
|
|
63
|
+
/** How the site addresses its item pages (`urls.style` in projection.json): roundup drafts link to published items with it. */
|
|
64
|
+
itemUrlStyle: ItemUrlStyle;
|
|
65
|
+
/** `papyrus-<siteId>` unless overridden. */
|
|
66
|
+
configurationSet: string;
|
|
67
|
+
/** `papyrus-newsletter-events-<siteId>` unless overridden. */
|
|
68
|
+
eventTopicName: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const KNOWN_KEYS = new Set([
|
|
72
|
+
"name", "siteId", "siteOrigin", "computeRoleArn", "sendingDomain", "fromAddress", "fromName", "instantAlerts", "theme", "lists",
|
|
73
|
+
"configurationSet", "eventTopicName", "itemUrlStyle",
|
|
74
|
+
]);
|
|
75
|
+
const LIST_KEYS = {
|
|
76
|
+
roundup: ["id", "name", "description", "kind", "schedule", "issueType", "archivePath"],
|
|
77
|
+
alert: ["id", "name", "description", "kind", "rule", "approval"],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function fail(message: string): never {
|
|
81
|
+
throw new Error(`newsletter plugin ${message}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function record(raw: unknown, label: string, known: string[]): Record<string, unknown> {
|
|
85
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) fail(`${label} must be an object`);
|
|
86
|
+
const unknown = Object.keys(raw).filter((key) => !known.includes(key));
|
|
87
|
+
if (unknown.length > 0) fail(`${label} has unknown settings: ${unknown.join(", ")}`);
|
|
88
|
+
return raw as Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function text(raw: unknown, label: string, maximum: number, options: { optional?: boolean; fallback?: string } = {}): string {
|
|
92
|
+
const value = raw === undefined ? options.fallback : raw;
|
|
93
|
+
if (value === undefined) fail(`${label} is required`);
|
|
94
|
+
if (typeof value !== "string" || CONTROL_CHARACTERS.test(value) || value.trim() !== value) {
|
|
95
|
+
fail(`${label} must be text on one line with no leading or trailing spaces`);
|
|
96
|
+
}
|
|
97
|
+
if (value.length === 0 && !options.optional) fail(`${label} is required`);
|
|
98
|
+
if (value.length > maximum) fail(`${label} must be at most ${maximum} characters`);
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function matching(raw: unknown, label: string, pattern: RegExp, description: string, fallback?: string): string {
|
|
103
|
+
const value = raw === undefined ? fallback : raw;
|
|
104
|
+
if (typeof value !== "string" || !pattern.test(value)) fail(`${label} must be ${description}`);
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function timeZone(raw: unknown, label: string): string {
|
|
109
|
+
const value = text(raw, label, 64);
|
|
110
|
+
try {
|
|
111
|
+
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
|
112
|
+
} catch {
|
|
113
|
+
fail(`${label} must be an IANA time zone such as America/New_York`);
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function origin(raw: unknown): string {
|
|
119
|
+
const value = text(raw, "siteOrigin", 200);
|
|
120
|
+
let parsed: URL | undefined;
|
|
121
|
+
try {
|
|
122
|
+
parsed = new URL(value);
|
|
123
|
+
} catch {
|
|
124
|
+
parsed = undefined;
|
|
125
|
+
}
|
|
126
|
+
if (!parsed || parsed.protocol !== "https:" || parsed.origin !== value) fail("siteOrigin must be an https origin such as https://example.com with no path");
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function stringList(raw: unknown, label: string): string[] | undefined {
|
|
131
|
+
if (raw === undefined) return undefined;
|
|
132
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.length > 50) fail(`${label} must be a list of 1 to 50 names`);
|
|
133
|
+
return [...new Set(raw.map((entry, index) => text(entry, `${label}[${index}]`, 80)))];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function itemUrlStyle(raw: unknown): ItemUrlStyle {
|
|
137
|
+
if (raw === undefined) return "html";
|
|
138
|
+
if (typeof raw !== "string" || !(ITEM_URL_STYLES as readonly string[]).includes(raw)) fail(`itemUrlStyle must be one of ${ITEM_URL_STYLES.join(", ")}`);
|
|
139
|
+
return raw as ItemUrlStyle;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resolveTheme(raw: unknown): NewsletterTheme {
|
|
143
|
+
const theme = record(raw, "theme", ["accent", "text", "background", "surface", "muted", "fontFamily", "logoUrl"]);
|
|
144
|
+
const resolved: NewsletterTheme = {
|
|
145
|
+
accent: text(theme.accent, "theme.accent", 80),
|
|
146
|
+
text: text(theme.text, "theme.text", 80),
|
|
147
|
+
background: text(theme.background, "theme.background", 80),
|
|
148
|
+
};
|
|
149
|
+
for (const key of ["surface", "muted", "fontFamily"] as const) {
|
|
150
|
+
if (theme[key] !== undefined) resolved[key] = text(theme[key], `theme.${key}`, 200);
|
|
151
|
+
}
|
|
152
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
153
|
+
if (UNSAFE_CSS.test(value)) fail(`theme.${key} contains characters that are not allowed in a style value`);
|
|
154
|
+
}
|
|
155
|
+
if (theme.logoUrl !== undefined) {
|
|
156
|
+
const logoUrl = text(theme.logoUrl, "theme.logoUrl", 500);
|
|
157
|
+
if (!/^https:\/\//.test(logoUrl)) fail("theme.logoUrl must be an https URL");
|
|
158
|
+
resolved.logoUrl = logoUrl;
|
|
159
|
+
}
|
|
160
|
+
return resolved;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function resolveRule(raw: unknown, label: string): NewsletterRule {
|
|
164
|
+
if (raw === undefined) return {};
|
|
165
|
+
const rule = record(raw, label, ["sections", "tags", "types"]);
|
|
166
|
+
return {
|
|
167
|
+
...(rule.sections === undefined ? {} : { sections: stringList(rule.sections, `${label}.sections`) }),
|
|
168
|
+
...(rule.tags === undefined ? {} : { tags: stringList(rule.tags, `${label}.tags`) }),
|
|
169
|
+
...(rule.types === undefined ? {} : { types: stringList(rule.types, `${label}.types`) }),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveList(raw: unknown, index: number, instantAlerts: boolean): NewsletterList {
|
|
174
|
+
const label = `lists[${index}]`;
|
|
175
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) fail(`${label} must be an object`);
|
|
176
|
+
const kind = (raw as Record<string, unknown>).kind;
|
|
177
|
+
if (kind !== "roundup" && kind !== "alert") 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") fail(`${label}.approval must be "admin" or "none"`);
|
|
199
|
+
if (approval === "none" && !instantAlerts) {
|
|
200
|
+
fail(`${label}.approval "none" sends alerts without an admin approving them: set instantAlerts: true on the plugin first`);
|
|
201
|
+
}
|
|
202
|
+
return { ...base, kind, rule: resolveRule(list.rule, `${label}.rule`), approval };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function resolveNewsletterConfig(request: PluginRequest): NewsletterConfig {
|
|
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") fail("instantAlerts must be a boolean");
|
|
219
|
+
const instantAlerts = raw.instantAlerts === true;
|
|
220
|
+
if (!Array.isArray(raw.lists) || raw.lists.length === 0 || raw.lists.length > 20) fail("lists must be a list of 1 to 20 lists");
|
|
221
|
+
const lists = raw.lists.map((entry, index) => resolveList(entry, index, instantAlerts));
|
|
222
|
+
const duplicates = lists.map((list) => list.id).filter((id, index, ids) => ids.indexOf(id) !== index);
|
|
223
|
+
if (duplicates.length > 0) fail(`lists has duplicate ids: ${[...new Set(duplicates)].join(", ")}`);
|
|
224
|
+
const roundups = lists.filter((list) => list.kind === "roundup");
|
|
225
|
+
if (new Set(roundups.map((list) => (list as RoundupList).archivePath)).size !== roundups.length) fail("roundup lists need different archivePath values");
|
|
226
|
+
return {
|
|
227
|
+
siteId,
|
|
228
|
+
siteOrigin: origin(raw.siteOrigin),
|
|
229
|
+
computeRoleArn,
|
|
230
|
+
sendingDomain,
|
|
231
|
+
fromAddress,
|
|
232
|
+
fromName: text(raw.fromName, "fromName", 80),
|
|
233
|
+
instantAlerts,
|
|
234
|
+
theme: resolveTheme(raw.theme),
|
|
235
|
+
lists,
|
|
236
|
+
itemUrlStyle: itemUrlStyle(raw.itemUrlStyle),
|
|
237
|
+
configurationSet: matching(raw.configurationSet, "configurationSet", SES_NAME, "an SES configuration set name", `papyrus-${siteId}`),
|
|
238
|
+
eventTopicName: matching(raw.eventTopicName, "eventTopicName", SES_NAME, "an SNS topic name", `papyrus-newsletter-events-${siteId}`),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Bundle entry kept for the shared Python bundler, which copies ``handler.py`` to the asset root.
|
|
2
|
+
|
|
3
|
+
The Lambda's handler is ``papyrus_content.newsletter.handler.handler`` (src/papyrus_content/newsletter/handler.py).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from papyrus_content.newsletter.handler import handler # noqa: F401
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Bundle entry for the newsroom's newsletter operations (AppSync): forwards to the ``newsletter`` function.
|
|
2
|
+
|
|
3
|
+
The logic is ``papyrus_content.newsletter.ops_proxy`` (src/papyrus_content/newsletter/ops_proxy.py).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from papyrus_content.newsletter.ops_proxy import handle
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def handler(event: dict[str, Any], _context: Any = None) -> Any:
|
|
12
|
+
return handle(event)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineFunction } from "@aws-amplify/backend";
|
|
2
|
+
import { Duration } from "aws-cdk-lib";
|
|
3
|
+
import { Architecture, Function, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
4
|
+
import { pythonLambdaCode } from "../../../functions/shared/python-bundle.js";
|
|
5
|
+
/** AppSync gives a resolver 30 seconds; this function only forwards to `newsletter`, which answers in a few. */
|
|
6
|
+
export const NEWSLETTER_OPS_TIMEOUT_SECONDS = 25;
|
|
7
|
+
/**
|
|
8
|
+
* The newsroom's newsletter operations enter here because an Amplify custom operation needs an Amplify-defined function and
|
|
9
|
+
* the `newsletter` function itself lives in the plugin stack. It holds no data access: it may invoke that one function.
|
|
10
|
+
*/
|
|
11
|
+
export const newsletterOps = defineFunction((scope) => new Function(scope, "papyrus-newsletter-ops", {
|
|
12
|
+
runtime: Runtime.PYTHON_3_12,
|
|
13
|
+
architecture: Architecture.ARM_64,
|
|
14
|
+
handler: "handler.handler",
|
|
15
|
+
timeout: Duration.seconds(NEWSLETTER_OPS_TIMEOUT_SECONDS),
|
|
16
|
+
memorySize: 256,
|
|
17
|
+
code: pythonLambdaCode({ functionDir: "../plugins/newsletter/ops", modules: ["papyrus_content"], noDeps: true }, [
|
|
18
|
+
"bash",
|
|
19
|
+
"-c",
|
|
20
|
+
[
|
|
21
|
+
"set -euo pipefail",
|
|
22
|
+
"mkdir -p /asset-output/papyrus_content",
|
|
23
|
+
"cp amplify/plugins/newsletter/ops/handler.py /asset-output/handler.py",
|
|
24
|
+
"cp -R src/papyrus_content/. /asset-output/papyrus_content/",
|
|
25
|
+
].join(" && "),
|
|
26
|
+
]),
|
|
27
|
+
}), { resourceGroupName: "data" });
|