@anthusai/papyrus 5.19.1 → 5.21.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/resource.js +1 -0
- package/amplify/data/resource.ts +1 -0
- package/amplify/data/schema.js +1 -0
- package/amplify/data/schema.ts +1 -0
- package/amplify/functions/assignment-action/data-access.js +2 -0
- package/amplify/functions/assignment-action/data-access.ts +2 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +20 -2
- package/amplify/functions/shared/editorial-context.ts +63 -4
- 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 +162 -0
- package/amplify/plugins/reader-steering/config.ts +248 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
- package/amplify/plugins/reader-steering/function/handler.py +1363 -0
- 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 +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +175 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +201 -0
- package/amplify/plugins/reader-steering/schema.js +140 -0
- package/amplify/plugins/reader-steering/schema.ts +142 -0
- package/amplify/plugins/registry.js +4 -0
- package/amplify/plugins/registry.ts +4 -0
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -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/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/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/reference-cyclotron-priming.tsx +54 -15
- package/components/topic-steering-workspace.tsx +42 -5
- package/infra/amplify-app-shell.js +15 -1
- package/infra/amplify-app-shell.ts +17 -1
- package/infra/site-config.js +12 -2
- package/infra/site-config.ts +19 -2
- package/lib/editorial-checks.ts +6 -2
- 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/prospectus-session.ts +1 -0
- package/lib/prospectus-sign-in-routes.ts +2 -2
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/package.json +27 -1
- package/routes.manifest.json +158 -1
package/amplify/data/resource.js
CHANGED
|
@@ -856,6 +856,7 @@ const schema = a.schema({
|
|
|
856
856
|
storyLineageId: a.id(),
|
|
857
857
|
assignmentId: a.id(),
|
|
858
858
|
maxCharacters: a.integer(),
|
|
859
|
+
optionalPublicationDoctrine: a.boolean(),
|
|
859
860
|
})
|
|
860
861
|
.returns(a.json().required())
|
|
861
862
|
.authorization((allow) => [allow.groups(categoryWriteGroups)])
|
package/amplify/data/resource.ts
CHANGED
|
@@ -931,6 +931,7 @@ const schema = a.schema({
|
|
|
931
931
|
storyLineageId: a.id(),
|
|
932
932
|
assignmentId: a.id(),
|
|
933
933
|
maxCharacters: a.integer(),
|
|
934
|
+
optionalPublicationDoctrine: a.boolean(),
|
|
934
935
|
})
|
|
935
936
|
.returns(a.json().required())
|
|
936
937
|
.authorization((allow) => [allow.groups(categoryWriteGroups)])
|
package/amplify/data/schema.js
CHANGED
|
@@ -781,6 +781,7 @@ function buildBaseDefinitions(includeEditions) {
|
|
|
781
781
|
storyLineageId: a.id(),
|
|
782
782
|
assignmentId: a.id(),
|
|
783
783
|
maxCharacters: a.integer(),
|
|
784
|
+
optionalPublicationDoctrine: a.boolean(),
|
|
784
785
|
})
|
|
785
786
|
.returns(a.json().required())
|
|
786
787
|
.authorization((allow) => [allow.groups(categoryWriteGroups)])
|
package/amplify/data/schema.ts
CHANGED
|
@@ -854,6 +854,7 @@ const definitions = {
|
|
|
854
854
|
storyLineageId: a.id(),
|
|
855
855
|
assignmentId: a.id(),
|
|
856
856
|
maxCharacters: a.integer(),
|
|
857
|
+
optionalPublicationDoctrine: a.boolean(),
|
|
857
858
|
})
|
|
858
859
|
.returns(a.json().required())
|
|
859
860
|
.authorization((allow) => [allow.groups(categoryWriteGroups)])
|
|
@@ -9,6 +9,8 @@ export const ASSIGNMENT_DATA_FIELDS = [
|
|
|
9
9
|
"Query/fields/getProcedureDefinition",
|
|
10
10
|
"Query/fields/getModelAttachment",
|
|
11
11
|
"Query/fields/getNewsroomSection",
|
|
12
|
+
"Query/fields/listNewsroomSections",
|
|
13
|
+
"Query/fields/listNewsroomSectionsByTenantAndSortOrder",
|
|
12
14
|
"Query/fields/getKnowledgeCorpus",
|
|
13
15
|
"Query/fields/getKnowledgeImportRun",
|
|
14
16
|
"Query/fields/getReference",
|
|
@@ -11,6 +11,8 @@ export const ASSIGNMENT_DATA_FIELDS = [
|
|
|
11
11
|
"Query/fields/getProcedureDefinition",
|
|
12
12
|
"Query/fields/getModelAttachment",
|
|
13
13
|
"Query/fields/getNewsroomSection",
|
|
14
|
+
"Query/fields/listNewsroomSections",
|
|
15
|
+
"Query/fields/listNewsroomSectionsByTenantAndSortOrder",
|
|
14
16
|
"Query/fields/getKnowledgeCorpus",
|
|
15
17
|
"Query/fields/getKnowledgeImportRun",
|
|
16
18
|
"Query/fields/getReference",
|
|
@@ -316,7 +316,7 @@ async function listAssignmentsForObject(event: Parameters<Schema["listAssignment
|
|
|
316
316
|
return contexts;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
-
async function editorialContext(client: DataClient, scope: { publicationKey?: string; sectionId?: string | null; storyLineageId?: string | null; assignmentId?: string | null; maxCharacters?: number | null }) {
|
|
319
|
+
async function editorialContext(client: DataClient, scope: { publicationKey?: string; sectionId?: string | null; storyLineageId?: string | null; assignmentId?: string | null; maxCharacters?: number | null; optionalPublicationDoctrine?: boolean | null }) {
|
|
320
320
|
if (!editorialEndpoint) throw new Error("Editorial context requires the configured backend endpoint.");
|
|
321
321
|
return resolveEditorialContext(client as unknown as EditorialClient, {
|
|
322
322
|
...scope, endpoint: editorialEndpoint, tenantId: tenantIdFromEnvironment(), readAttachment: readEditorialAttachment,
|
|
@@ -9,6 +9,7 @@ from papyrus_content.graphql_authoring import create_authoring_client
|
|
|
9
9
|
from papyrus_content.markus_renderer.derive import derive_body
|
|
10
10
|
from papyrus_content.projection.deploy_render import deploy_render, drift_metric_line
|
|
11
11
|
from papyrus_content.projection.engine import project, project_all, project_item_pages
|
|
12
|
+
from papyrus_content.reader_steering.task import TASK_NAME as READER_SNAPSHOT_TASK, run_reader_snapshot
|
|
12
13
|
from papyrus_content.projection.runtime import (
|
|
13
14
|
DEAD_LETTER_QUEUE_ENV,
|
|
14
15
|
context_from_environment,
|
|
@@ -26,6 +27,7 @@ from papyrus_content.story_reporting import save_story_version
|
|
|
26
27
|
from papyrus_content.publication import ensure_publication_from_environment
|
|
27
28
|
from papyrus_content.editorial_checks import GraphQLEditorialStore, ensure_default_policies
|
|
28
29
|
from papyrus_content.reference_intake import IntakeError, intake_references
|
|
30
|
+
from papyrus_content.publish_alerts import PUBLISHED_TASK_KEY, announce_published
|
|
29
31
|
from papyrus_content.publishing import (
|
|
30
32
|
ItemFields,
|
|
31
33
|
PublishError,
|
|
@@ -118,11 +120,17 @@ def projection_context(client):
|
|
|
118
120
|
return context
|
|
119
121
|
|
|
120
122
|
|
|
121
|
-
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."""
|
|
122
126
|
if not projection_configured():
|
|
127
|
+
if published_item_id:
|
|
128
|
+
announce_published(item_id, published_item_id)
|
|
123
129
|
return {}
|
|
124
130
|
liveness = {} if unpublished else {"live": False}
|
|
125
131
|
task = {"task": "item", "itemId": item_id, "unpublished": unpublished}
|
|
132
|
+
if published_item_id:
|
|
133
|
+
task[PUBLISHED_TASK_KEY] = {"publishedItemId": published_item_id}
|
|
126
134
|
try:
|
|
127
135
|
report = project(projection_context(client), item_id, unpublished=unpublished)
|
|
128
136
|
except Exception as error: # noqa: BLE001 - a projection failure never fails the save
|
|
@@ -144,6 +152,8 @@ def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
|
|
|
144
152
|
schedule_unpurged_invalidation(report.unpurged)
|
|
145
153
|
if report.follow_up_item_pages:
|
|
146
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)
|
|
147
157
|
return response
|
|
148
158
|
|
|
149
159
|
|
|
@@ -187,6 +197,8 @@ def run_projection_task(task: dict) -> dict:
|
|
|
187
197
|
report = project_all(projection_context(client))
|
|
188
198
|
elif task.get("task") == "deploy-render":
|
|
189
199
|
return run_deploy_render_task(client)
|
|
200
|
+
elif task.get("task") == READER_SNAPSHOT_TASK:
|
|
201
|
+
return run_reader_snapshot(projection_context(client), client, poll_retirements=task.get("retirementPoll") is True).to_dict()
|
|
190
202
|
elif task.get("task") == "invalidate":
|
|
191
203
|
invalidate = projection_context(client).invalidate
|
|
192
204
|
if invalidate is None:
|
|
@@ -198,6 +210,10 @@ def run_projection_task(task: dict) -> dict:
|
|
|
198
210
|
schedule_unpurged_invalidation(report.unpurged)
|
|
199
211
|
if not report.ok:
|
|
200
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"))
|
|
201
217
|
return {"ok": True, "paths": [*report.written, *report.deleted]}
|
|
202
218
|
|
|
203
219
|
|
|
@@ -283,7 +299,9 @@ def publish_item_action(arguments: dict, actor: str) -> dict:
|
|
|
283
299
|
}
|
|
284
300
|
if result.changed:
|
|
285
301
|
response.update(site_triggers_for(client, arguments["id"]))
|
|
286
|
-
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
|
+
)
|
|
287
305
|
return response
|
|
288
306
|
|
|
289
307
|
|
|
@@ -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
|
|
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
|
-
|
|
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>>) {
|
|
@@ -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
|
+
}
|