@anthusai/papyrus 5.33.1 → 5.34.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/site-backend-config.ts +1 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +1 -1
- package/amplify/site-backend.ts +1 -1
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsroom-access-gate.tsx +15 -1
- package/components/newsroom-article-editor.tsx +4 -2
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-ops-overview.tsx +12 -100
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +2 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +179 -36
- package/components/notification-center.tsx +13 -2
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-cyclotron-suggestion.tsx +9 -5
- package/components/reference-relevance-review.tsx +3 -3
- package/components/topic-steering-workspace.tsx +144 -123
- package/infra/amplify-app-shell.js +4 -0
- package/infra/amplify-app-shell.ts +4 -0
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/site-config.js +4 -2
- package/infra/site-config.ts +7 -2
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/use-reference-review-data.ts +44 -0
- package/package.json +5 -1
- package/routes.manifest.json +1 -1
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
loadEditorReferencesData,
|
|
41
41
|
loadEditorProcedureData,
|
|
42
42
|
loadEditorSemanticRelationsData,
|
|
43
|
+
loadEditorReferenceReviewDetails,
|
|
43
44
|
loadEditionForumThreads,
|
|
44
45
|
loadNewsroomAssignmentPage,
|
|
45
46
|
loadNewsroomMessagePage,
|
|
@@ -167,6 +168,9 @@ import {
|
|
|
167
168
|
} from "../lib/reference-policy";
|
|
168
169
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
169
170
|
import { currentRelevanceDecisions } from "../lib/relevance-decisions";
|
|
171
|
+
import { runReferenceReviewRequest } from "../lib/reference-review-request";
|
|
172
|
+
import { useReferenceReviewData } from "../lib/use-reference-review-data";
|
|
173
|
+
import { latestReferenceReviewReasons, latestReferenceReviewSections } from "../lib/reference-review-details";
|
|
170
174
|
import { delegatedCurationsByReference, referencesWithHumanLatestCuration, type ShadowAgreement } from "../lib/cyclotron-suggestions";
|
|
171
175
|
import { createLocalPreviewCyclotronFixtures } from "../lib/local-preview-seed";
|
|
172
176
|
import { createLocalPreviewSectionFixtures } from "../lib/local-preview-section-fixtures";
|
|
@@ -670,7 +674,7 @@ export function NewsDeskWorkspace({
|
|
|
670
674
|
);
|
|
671
675
|
}
|
|
672
676
|
|
|
673
|
-
if (session && sessionDashboard) {
|
|
677
|
+
if (session && sessionDashboard && session.shell.phase !== "loadingDesk") {
|
|
674
678
|
return (
|
|
675
679
|
<NewsDeskDashboard
|
|
676
680
|
analysisProfiles={analysisProfiles}
|
|
@@ -902,6 +906,11 @@ function NewsDeskDashboard({
|
|
|
902
906
|
const [referencesRealtimeStatus, setReferencesRealtimeStatus] = useState<RealtimeSubscriptionStatus>("idle");
|
|
903
907
|
const [referencesRealtimeError, setReferencesRealtimeError] = useState<string | null>(null);
|
|
904
908
|
const referencesRef = useRef(references);
|
|
909
|
+
const referenceReviewsPending = useRef(new Map<string, Promise<void>>());
|
|
910
|
+
const [selectedReviewRead, setSelectedReviewRead] = useState<{ lineageId: string; state: "loading" | "ready" | "error"; error: string | null }>({ lineageId: "", state: "ready", error: null });
|
|
911
|
+
const [selectedReviewAttempt, setSelectedReviewAttempt] = useState(0);
|
|
912
|
+
const reviewReasons = useMemo(() => latestReferenceReviewReasons(messages), [messages]);
|
|
913
|
+
const reviewSectionIds = useMemo(() => latestReferenceReviewSections(messages), [messages]);
|
|
905
914
|
|
|
906
915
|
useEffect(() => {
|
|
907
916
|
referencesRef.current = references;
|
|
@@ -1231,17 +1240,6 @@ function NewsDeskDashboard({
|
|
|
1231
1240
|
if (dashboard.isDemo) return;
|
|
1232
1241
|
let active = true;
|
|
1233
1242
|
|
|
1234
|
-
if (activeTab === "messages" && !loadedSections.messages) {
|
|
1235
|
-
setLoadedSections((current) => ({ ...current, messages: true }));
|
|
1236
|
-
void loadEditorMessagesData()
|
|
1237
|
-
.then((nextMessages) => {
|
|
1238
|
-
if (active) setMessages(nextMessages);
|
|
1239
|
-
})
|
|
1240
|
-
.catch((error) => {
|
|
1241
|
-
if (active) setActionState({ id: "messages-load", message: error instanceof Error ? error.message : "messages load failed", tone: "error" });
|
|
1242
|
-
});
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
1243
|
if (activeTab === "assignments" && !loadedSections.assignments) {
|
|
1246
1244
|
setLoadedSections((current) => ({ ...current, assignments: true }));
|
|
1247
1245
|
void loadEditorAssignmentsData()
|
|
@@ -1269,23 +1267,6 @@ function NewsDeskDashboard({
|
|
|
1269
1267
|
});
|
|
1270
1268
|
}
|
|
1271
1269
|
|
|
1272
|
-
if ((activeTab === "assignments" || activeTab === "references" || activeTab === "messages") && !loadedSections.semanticRelations) {
|
|
1273
|
-
setLoadedSections((current) => ({ ...current, semanticRelations: true }));
|
|
1274
|
-
void loadEditorSemanticRelationsData()
|
|
1275
|
-
.then((relations) => {
|
|
1276
|
-
if (active) setSemanticRelations(relations);
|
|
1277
|
-
})
|
|
1278
|
-
.catch(() => {
|
|
1279
|
-
if (active) setSemanticRelations([]);
|
|
1280
|
-
});
|
|
1281
|
-
if (!loadedSections.messages) {
|
|
1282
|
-
setLoadedSections((current) => ({ ...current, messages: true }));
|
|
1283
|
-
void loadEditorMessagesData().then((nextMessages) => {
|
|
1284
|
-
if (active) setMessages(nextMessages);
|
|
1285
|
-
}).catch(() => undefined);
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
1270
|
if ((activeTab === "topics" || activeTab === "desks" || activeTab === "concepts" || activeTab === "administration") && !loadedSections.fullDashboard) {
|
|
1290
1271
|
setLoadedSections((current) => ({ ...current, fullDashboard: true }));
|
|
1291
1272
|
void loadEditorFullNewsDeskDashboard({ isAdmin: Boolean(dashboard.canManageUsers) })
|
|
@@ -1340,6 +1321,37 @@ function NewsDeskDashboard({
|
|
|
1340
1321
|
refreshNewsroomSections,
|
|
1341
1322
|
]);
|
|
1342
1323
|
|
|
1324
|
+
const needsReferenceReviewData = activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
|
|
1325
|
+
const selectedReviewLineageId = pathname ? pathnameReferenceLineageId ?? "" : initialSelection.reference ?? "";
|
|
1326
|
+
const referenceReviewData = useReferenceReviewData({
|
|
1327
|
+
enabled: canLoadPrivateCorpus && needsReferenceReviewData && !(activeTab === "references" && selectedReviewLineageId),
|
|
1328
|
+
loadRelations: loadEditorSemanticRelationsData,
|
|
1329
|
+
loadMessages: loadEditorMessagesData,
|
|
1330
|
+
onRelations: setSemanticRelations,
|
|
1331
|
+
onMessages: setMessages,
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
useEffect(() => {
|
|
1335
|
+
if (!canLoadPrivateCorpus || activeTab !== "references" || !selectedReviewLineageId) return;
|
|
1336
|
+
let active = true;
|
|
1337
|
+
setSelectedReviewRead({ lineageId: selectedReviewLineageId, state: "loading", error: null });
|
|
1338
|
+
void loadEditorReferenceReviewDetails(selectedReviewLineageId).then(({ relations, messages: history }) => {
|
|
1339
|
+
if (!active) return;
|
|
1340
|
+
setSemanticRelations((current) => [...current.filter((relation) => relation.subjectLineageId !== selectedReviewLineageId || relation.subjectKind !== "reference"), ...relations]);
|
|
1341
|
+
setMessages((current) => [...current.filter((message) => !history.some((entry) => entry.id === message.id)), ...history]);
|
|
1342
|
+
setSelectedReviewRead({ lineageId: selectedReviewLineageId, state: "ready", error: null });
|
|
1343
|
+
}).catch((error) => {
|
|
1344
|
+
if (active) setSelectedReviewRead({ lineageId: selectedReviewLineageId, state: "error", error: error instanceof Error ? error.message : "Reference review details could not be loaded." });
|
|
1345
|
+
});
|
|
1346
|
+
return () => { active = false; };
|
|
1347
|
+
}, [activeTab, canLoadPrivateCorpus, selectedReviewLineageId, selectedReviewAttempt]);
|
|
1348
|
+
const selectedReviewIsCurrent = selectedReviewLineageId && !dashboard.isDemo;
|
|
1349
|
+
const predictionState = selectedReviewIsCurrent
|
|
1350
|
+
? selectedReviewRead.lineageId === selectedReviewLineageId ? selectedReviewRead.state : "loading"
|
|
1351
|
+
: referenceReviewData.state;
|
|
1352
|
+
const predictionError = selectedReviewIsCurrent ? selectedReviewRead.error : referenceReviewData.error;
|
|
1353
|
+
const retryReferenceReviewData = () => { referenceReviewData.retry(); setSelectedReviewAttempt((current) => current + 1); };
|
|
1354
|
+
|
|
1343
1355
|
const applyReferenceRecord = useCallback((nextReference: ReferenceRecord) => {
|
|
1344
1356
|
const { nextRecords, nextRecord, previousRecord } = upsertReferenceRecords(referencesRef.current, nextReference);
|
|
1345
1357
|
referencesRef.current = nextRecords;
|
|
@@ -1594,96 +1606,98 @@ function NewsDeskDashboard({
|
|
|
1594
1606
|
reasonCode?: ReferenceRejectionReasonCode | null,
|
|
1595
1607
|
relevance?: { decisionRelationId: string; shareable: boolean },
|
|
1596
1608
|
sectionId?: string | null,
|
|
1597
|
-
) {
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
note: note?.trim() || undefined,
|
|
1666
|
-
reasonCode: action === "reject" ? reasonCode ?? undefined : undefined,
|
|
1667
|
-
decisionRelationId: relevance?.decisionRelationId,
|
|
1668
|
-
shareable: relevance ? relevance.shareable : undefined,
|
|
1669
|
-
sectionId: action === "accept" ? sectionId ?? undefined : undefined,
|
|
1670
|
-
},
|
|
1671
|
-
{ authMode: USER_POOL_AUTH_MODE },
|
|
1672
|
-
);
|
|
1673
|
-
const review = assertReferenceReviewMutationSucceeded(response, reference.id);
|
|
1674
|
-
const status = review.status ?? nextStatus;
|
|
1675
|
-
const now = new Date().toISOString();
|
|
1676
|
-
applyReferenceRecord(buildReviewedReferenceRecord(referencesRef.current, reference, {
|
|
1609
|
+
): Promise<void> {
|
|
1610
|
+
return runReferenceReviewRequest(referenceReviewsPending.current, reference.lineageId ?? reference.id, async () => {
|
|
1611
|
+
if (!canEdit) throw new Error("Your session does not have permission to review references.");
|
|
1612
|
+
const nextStatus = referenceCurationStatusForAction(action);
|
|
1613
|
+
setActionState({ id: reference.id, message: `${action} pending`, tone: "pending" });
|
|
1614
|
+
if (dashboard.isDemo) {
|
|
1615
|
+
const now = new Date().toISOString();
|
|
1616
|
+
const messageId = `message-demo-${reference.id}-${action}-${now.replace(/[^0-9]/g, "")}`;
|
|
1617
|
+
const relationId = `semantic-relation-demo-${messageId}`;
|
|
1618
|
+
applyReferenceRecord(buildReviewedReferenceRecord(referencesRef.current, reference, {
|
|
1619
|
+
actorLabel: authState.label,
|
|
1620
|
+
nextStatus,
|
|
1621
|
+
note: note ?? null,
|
|
1622
|
+
now,
|
|
1623
|
+
}));
|
|
1624
|
+
setMessages((current) => [{
|
|
1625
|
+
id: messageId,
|
|
1626
|
+
messageKind: "reference_curation",
|
|
1627
|
+
messageDomain: "commentary",
|
|
1628
|
+
status: "active",
|
|
1629
|
+
body: note?.trim() || `${authState.label} marked this reference ${nextStatus}.`,
|
|
1630
|
+
summary: `${reference.title ?? reference.externalItemId}: ${nextStatus}`,
|
|
1631
|
+
source: "newsroom",
|
|
1632
|
+
authorLabel: authState.label,
|
|
1633
|
+
createdAt: now,
|
|
1634
|
+
updatedAt: now,
|
|
1635
|
+
metadata: {
|
|
1636
|
+
referenceLineageId: reference.lineageId ?? reference.id,
|
|
1637
|
+
action,
|
|
1638
|
+
curationStatus: nextStatus,
|
|
1639
|
+
reasonCode: action === "reject" ? reasonCode ?? null : null,
|
|
1640
|
+
...(relevance ? { decisionRelationId: relevance.decisionRelationId, shareable: relevance.shareable } : {}),
|
|
1641
|
+
...(action === "accept" && sectionId ? { sectionId } : {}),
|
|
1642
|
+
},
|
|
1643
|
+
}, ...current]);
|
|
1644
|
+
setSemanticRelations((current) => [{
|
|
1645
|
+
id: relationId,
|
|
1646
|
+
relationState: "current",
|
|
1647
|
+
predicate: "comment",
|
|
1648
|
+
relationTypeId: "semantic-relation-type-comment",
|
|
1649
|
+
relationTypeKey: "comment",
|
|
1650
|
+
relationDomain: "commentary",
|
|
1651
|
+
subjectKind: "message",
|
|
1652
|
+
subjectId: messageId,
|
|
1653
|
+
subjectLineageId: messageId,
|
|
1654
|
+
subjectVersionNumber: 1,
|
|
1655
|
+
objectKind: "reference",
|
|
1656
|
+
objectId: reference.id,
|
|
1657
|
+
objectLineageId: reference.lineageId ?? reference.id,
|
|
1658
|
+
objectVersionNumber: reference.versionNumber ?? null,
|
|
1659
|
+
subjectStateKey: `message#${messageId}#current`,
|
|
1660
|
+
objectStateKey: `reference#${reference.lineageId ?? reference.id}#current`,
|
|
1661
|
+
objectSubjectStateKey: `reference#${reference.lineageId ?? reference.id}#current#message`,
|
|
1662
|
+
predicateObjectStateKey: `comment#reference#${reference.lineageId ?? reference.id}#current`,
|
|
1663
|
+
subjectVersionKey: `message#${messageId}`,
|
|
1664
|
+
objectVersionKey: `reference#${reference.id}`,
|
|
1665
|
+
rank: 1,
|
|
1666
|
+
reviewRecommended: false,
|
|
1667
|
+
importedAt: now,
|
|
1668
|
+
}, ...current]);
|
|
1669
|
+
setActionState({ id: reference.id, message: `${action} saved`, tone: "ok" });
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
try {
|
|
1673
|
+
const response = await dataClient.mutations.reviewReferenceCuration(
|
|
1674
|
+
{
|
|
1675
|
+
referenceId: reference.id,
|
|
1676
|
+
action,
|
|
1677
1677
|
actorLabel: authState.label,
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1678
|
+
note: note?.trim() || undefined,
|
|
1679
|
+
reasonCode: action === "reject" ? reasonCode ?? undefined : undefined,
|
|
1680
|
+
decisionRelationId: relevance?.decisionRelationId,
|
|
1681
|
+
shareable: relevance ? relevance.shareable : undefined,
|
|
1682
|
+
sectionId: action === "accept" ? sectionId ?? undefined : undefined,
|
|
1683
|
+
},
|
|
1684
|
+
{ authMode: USER_POOL_AUTH_MODE },
|
|
1685
|
+
);
|
|
1686
|
+
const review = assertReferenceReviewMutationSucceeded(response, reference.id);
|
|
1687
|
+
const status = review.status ?? nextStatus;
|
|
1688
|
+
const now = new Date().toISOString();
|
|
1689
|
+
applyReferenceRecord(buildReviewedReferenceRecord(referencesRef.current, reference, {
|
|
1690
|
+
actorLabel: authState.label,
|
|
1691
|
+
nextStatus: status,
|
|
1692
|
+
note: note?.trim() || null,
|
|
1693
|
+
now,
|
|
1694
|
+
}));
|
|
1695
|
+
setActionState({ id: reference.id, message: `${action} saved`, tone: "ok" });
|
|
1696
|
+
retryReferenceReviewData();
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
setActionState({ id: reference.id, message: error instanceof Error ? error.message : `${action} failed`, tone: "error" });
|
|
1699
|
+
throw error;
|
|
1700
|
+
}
|
|
1687
1701
|
});
|
|
1688
1702
|
}
|
|
1689
1703
|
|
|
@@ -3067,7 +3081,6 @@ function NewsDeskDashboard({
|
|
|
3067
3081
|
decisions={relevanceDecisions}
|
|
3068
3082
|
demo={Boolean(dashboard.isDemo)}
|
|
3069
3083
|
humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
|
|
3070
|
-
messages={messages}
|
|
3071
3084
|
newsroomSections={newsroomSections}
|
|
3072
3085
|
references={references}
|
|
3073
3086
|
/>
|
|
@@ -3127,7 +3140,7 @@ function NewsDeskDashboard({
|
|
|
3127
3140
|
<NewsroomReferencesView
|
|
3128
3141
|
demo={Boolean(dashboard.isDemo)}
|
|
3129
3142
|
disabled={controlsDisabled}
|
|
3130
|
-
initialReferenceLineageId={
|
|
3143
|
+
initialReferenceLineageId={selectedReviewLineageId}
|
|
3131
3144
|
cyclotronStatus={cyclotronStatus}
|
|
3132
3145
|
cyclotronPriming={{
|
|
3133
3146
|
labels: primingLabels,
|
|
@@ -3139,8 +3152,8 @@ function NewsDeskDashboard({
|
|
|
3139
3152
|
},
|
|
3140
3153
|
}}
|
|
3141
3154
|
decisions={relevanceDecisions}
|
|
3142
|
-
humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
|
|
3143
|
-
delegatedCurations={delegatedCurations}
|
|
3155
|
+
humanCuratedReferenceLineageIds={selectedReviewIsCurrent && predictionState !== "ready" ? undefined : humanCuratedReferenceLineageIds}
|
|
3156
|
+
delegatedCurations={selectedReviewIsCurrent && predictionState !== "ready" ? undefined : delegatedCurations}
|
|
3144
3157
|
shadowAgreement={shadowAgreement}
|
|
3145
3158
|
onRequestReviewRate={relevanceCyclotronId ? async (choice) => {
|
|
3146
3159
|
const request = buildReviewRateRequest(choice, authState.label);
|
|
@@ -3153,11 +3166,19 @@ function NewsDeskDashboard({
|
|
|
3153
3166
|
curation.note ?? undefined,
|
|
3154
3167
|
normalizeReferenceRejectionReasonCode(curation.reasonCode),
|
|
3155
3168
|
{ decisionRelationId: curation.decisionRelationId, shareable: curation.shareable },
|
|
3169
|
+
curation.sectionId,
|
|
3156
3170
|
)}
|
|
3157
3171
|
sections={sectionChoices}
|
|
3158
3172
|
sectionSuggestions={sectionSuggestions}
|
|
3159
3173
|
sectionAgreement={siteSectionAgreement}
|
|
3160
|
-
|
|
3174
|
+
predictionState={predictionState}
|
|
3175
|
+
predictionError={predictionError}
|
|
3176
|
+
onRetryPredictions={retryReferenceReviewData}
|
|
3177
|
+
reviewReasons={reviewReasons}
|
|
3178
|
+
reviewSectionIds={reviewSectionIds}
|
|
3179
|
+
onReview={(reference, action, options) => runReferenceCurationAction(reference, action, options?.note, options?.reasonCode,
|
|
3180
|
+
options?.decisionRelationId ? { decisionRelationId: options.decisionRelationId, shareable: options.shareable === true } : undefined,
|
|
3181
|
+
options?.sectionId)}
|
|
3161
3182
|
referenceAttachments={referenceAttachments}
|
|
3162
3183
|
references={references}
|
|
3163
3184
|
/>
|
|
@@ -2,6 +2,8 @@ import { ArnFormat, CfnOutput, Duration, Fn, Stack } from "aws-cdk-lib";
|
|
|
2
2
|
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
3
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
4
4
|
import { SecureStringParameter } from "./secure-string-parameter.js";
|
|
5
|
+
import { addCloudEmail } from "./cloud-email.js";
|
|
6
|
+
import { parseCloudEmail } from "./cloud-email-config.js";
|
|
5
7
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
|
|
6
8
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
|
|
7
9
|
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
@@ -74,6 +76,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
74
76
|
return;
|
|
75
77
|
}
|
|
76
78
|
const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
|
|
79
|
+
const emailEnvironment = addCloudEmail(this, parseCloudEmail(config.email, true, config.siteId));
|
|
77
80
|
const stagingEnabled = isStagingEnabled(config);
|
|
78
81
|
const stagingDomainName = resolveStagingDomainName(config);
|
|
79
82
|
const cmsHostName = resolveCmsHostName(config);
|
|
@@ -195,6 +198,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
195
198
|
enablePerformanceMode: false,
|
|
196
199
|
environmentVariables: environmentVariables({
|
|
197
200
|
...config.cms.environment,
|
|
201
|
+
...emailEnvironment,
|
|
198
202
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
199
203
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
200
204
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
@@ -3,6 +3,8 @@ import { ArnFormat, CfnOutput, Duration, Fn, Stack, StackProps } from "aws-cdk-l
|
|
|
3
3
|
import * as iam from "aws-cdk-lib/aws-iam";
|
|
4
4
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
5
5
|
import { SecureStringParameter } from "./secure-string-parameter";
|
|
6
|
+
import { addCloudEmail } from "./cloud-email";
|
|
7
|
+
import { parseCloudEmail } from "./cloud-email-config";
|
|
6
8
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
|
|
7
9
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
|
|
8
10
|
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
@@ -79,6 +81,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
|
|
84
|
+
const emailEnvironment = addCloudEmail(this, parseCloudEmail(config.email, true, config.siteId)!);
|
|
82
85
|
const stagingEnabled = isStagingEnabled(config);
|
|
83
86
|
const stagingDomainName = resolveStagingDomainName(config);
|
|
84
87
|
const cmsHostName = resolveCmsHostName(config);
|
|
@@ -217,6 +220,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
217
220
|
enablePerformanceMode: false,
|
|
218
221
|
environmentVariables: environmentVariables({
|
|
219
222
|
...config.cms.environment,
|
|
223
|
+
...emailEnvironment,
|
|
220
224
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
221
225
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
222
226
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function parseCloudEmail(raw, cloud, siteId) {
|
|
2
|
+
if (!cloud) {
|
|
3
|
+
if (raw !== undefined)
|
|
4
|
+
throw new Error("site.json: email requires a cloud cms; static-only sites do not need email resources");
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
8
|
+
throw new Error("site.json: email is required for cloud sites; configure mode managed or existing and an explicit sendingDomain (see docs/email-reference-intake.md)");
|
|
9
|
+
}
|
|
10
|
+
const value = raw;
|
|
11
|
+
for (const key of Object.keys(value)) {
|
|
12
|
+
if (!["mode", "sendingDomain", "senderLocalPart", "configurationSet", "eventTopicName"].includes(key)) {
|
|
13
|
+
throw new Error(`site.json: email.${key} is not a known field`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (value.mode !== "managed" && value.mode !== "existing")
|
|
17
|
+
throw new Error("site.json: email.mode must be managed or existing");
|
|
18
|
+
const string = (key, pattern, fallback) => {
|
|
19
|
+
const result = value[key] ?? fallback;
|
|
20
|
+
if (typeof result !== "string" || !pattern.test(result))
|
|
21
|
+
throw new Error(`site.json: email.${key} is required and must be valid`);
|
|
22
|
+
return result;
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
mode: value.mode,
|
|
26
|
+
sendingDomain: string("sendingDomain", /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/),
|
|
27
|
+
senderLocalPart: string("senderLocalPart", /^[a-z0-9][a-z0-9._+-]{0,63}$/, "notifications"),
|
|
28
|
+
configurationSet: string("configurationSet", /^[A-Za-z0-9_-]{1,64}$/, value.mode === "managed" ? `papyrus-${siteId}` : undefined),
|
|
29
|
+
// Preserve the newsletter consumer's historical default; the topic now also serves notifications.
|
|
30
|
+
eventTopicName: string("eventTopicName", /^[A-Za-z0-9_-]{1,256}$/, value.mode === "managed" ? `papyrus-newsletter-events-${siteId}` : undefined),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Email belongs to a cloud site, independently of any newsletter plugin. */
|
|
2
|
+
export type CloudEmailConfig = {
|
|
3
|
+
mode: "managed" | "existing";
|
|
4
|
+
sendingDomain: string;
|
|
5
|
+
senderLocalPart: string;
|
|
6
|
+
configurationSet: string;
|
|
7
|
+
eventTopicName: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function parseCloudEmail(raw: unknown, cloud: boolean, siteId: string): CloudEmailConfig | undefined {
|
|
11
|
+
if (!cloud) {
|
|
12
|
+
if (raw !== undefined) throw new Error("site.json: email requires a cloud cms; static-only sites do not need email resources");
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
16
|
+
throw new Error("site.json: email is required for cloud sites; configure mode managed or existing and an explicit sendingDomain (see docs/email-reference-intake.md)");
|
|
17
|
+
}
|
|
18
|
+
const value = raw as Record<string, unknown>;
|
|
19
|
+
for (const key of Object.keys(value)) {
|
|
20
|
+
if (!["mode", "sendingDomain", "senderLocalPart", "configurationSet", "eventTopicName"].includes(key)) {
|
|
21
|
+
throw new Error(`site.json: email.${key} is not a known field`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (value.mode !== "managed" && value.mode !== "existing") throw new Error("site.json: email.mode must be managed or existing");
|
|
25
|
+
const string = (key: string, pattern: RegExp, fallback?: string) => {
|
|
26
|
+
const result = value[key] ?? fallback;
|
|
27
|
+
if (typeof result !== "string" || !pattern.test(result)) throw new Error(`site.json: email.${key} is required and must be valid`);
|
|
28
|
+
return result;
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
mode: value.mode,
|
|
32
|
+
sendingDomain: string("sendingDomain", /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/),
|
|
33
|
+
senderLocalPart: string("senderLocalPart", /^[a-z0-9][a-z0-9._+-]{0,63}$/, "notifications"),
|
|
34
|
+
configurationSet: string("configurationSet", /^[A-Za-z0-9_-]{1,64}$/, value.mode === "managed" ? `papyrus-${siteId}` : undefined),
|
|
35
|
+
// Preserve the newsletter consumer's historical default; the topic now also serves notifications.
|
|
36
|
+
eventTopicName: string("eventTopicName", /^[A-Za-z0-9_-]{1,256}$/, value.mode === "managed" ? `papyrus-newsletter-events-${siteId}` : undefined),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ArnFormat, CfnOutput, RemovalPolicy } from "aws-cdk-lib";
|
|
2
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
|
+
import * as ses from "aws-cdk-lib/aws-ses";
|
|
4
|
+
import * as sns from "aws-cdk-lib/aws-sns";
|
|
5
|
+
/** No DNS mutation, sending grant or receipt activation. Existing identities remain with their owner. */
|
|
6
|
+
export function addCloudEmail(stack, config) {
|
|
7
|
+
if (config.mode === "managed") {
|
|
8
|
+
const configurationSet = new ses.CfnConfigurationSet(stack, "CloudEmailConfigurationSet", {
|
|
9
|
+
name: config.configurationSet,
|
|
10
|
+
reputationOptions: { reputationMetricsEnabled: true },
|
|
11
|
+
suppressionOptions: { suppressedReasons: ["BOUNCE", "COMPLAINT"] },
|
|
12
|
+
sendingOptions: { sendingEnabled: true },
|
|
13
|
+
});
|
|
14
|
+
configurationSet.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
15
|
+
const identity = new ses.CfnEmailIdentity(stack, "CloudEmailIdentity", {
|
|
16
|
+
emailIdentity: config.sendingDomain,
|
|
17
|
+
dkimSigningAttributes: { nextSigningKeyLength: "RSA_2048_BIT" },
|
|
18
|
+
configurationSetAttributes: { configurationSetName: configurationSet.ref },
|
|
19
|
+
});
|
|
20
|
+
identity.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
21
|
+
const topic = new sns.Topic(stack, "CloudEmailEvents", { topicName: config.eventTopicName });
|
|
22
|
+
topic.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
23
|
+
topic.addToResourcePolicy(new iam.PolicyStatement({
|
|
24
|
+
principals: [new iam.ServicePrincipal("ses.amazonaws.com")], actions: ["sns:Publish"],
|
|
25
|
+
resources: [topic.topicArn], conditions: { StringEquals: { "aws:SourceAccount": stack.account } },
|
|
26
|
+
}));
|
|
27
|
+
const events = new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailEventDestination", {
|
|
28
|
+
configurationSetName: configurationSet.ref,
|
|
29
|
+
eventDestination: { name: "delivery-events", enabled: true,
|
|
30
|
+
matchingEventTypes: ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT", "REJECT", "RENDERING_FAILURE", "DELIVERY_DELAY"],
|
|
31
|
+
snsDestination: { topicArn: topic.topicArn } },
|
|
32
|
+
});
|
|
33
|
+
events.node.addDependency(topic);
|
|
34
|
+
// These dimensions also feed the newsletter consumer's existing bounce/complaint alarms.
|
|
35
|
+
new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailMetrics", {
|
|
36
|
+
configurationSetName: configurationSet.ref,
|
|
37
|
+
eventDestination: { name: "cloudwatch-metrics", enabled: true,
|
|
38
|
+
matchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
|
|
39
|
+
cloudWatchDestination: { dimensionConfigurations: [{ dimensionName: "ses:configuration-set",
|
|
40
|
+
dimensionValueSource: "MESSAGE_TAG", defaultDimensionValue: config.configurationSet }] } },
|
|
41
|
+
});
|
|
42
|
+
const records = [[identity.attrDkimDnsTokenName1, identity.attrDkimDnsTokenValue1],
|
|
43
|
+
[identity.attrDkimDnsTokenName2, identity.attrDkimDnsTokenValue2],
|
|
44
|
+
[identity.attrDkimDnsTokenName3, identity.attrDkimDnsTokenValue3]];
|
|
45
|
+
records.forEach(([name, value], index) => {
|
|
46
|
+
new CfnOutput(stack, `EmailDkimRecord${index + 1}Name`, { value: name, description: "CNAME name; DNS change requires separate approval" });
|
|
47
|
+
new CfnOutput(stack, `EmailDkimRecord${index + 1}Value`, { value });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const identityArn = stack.formatArn({ service: "ses", resource: "identity", resourceName: config.sendingDomain, arnFormat: ArnFormat.SLASH_RESOURCE_NAME });
|
|
51
|
+
new CfnOutput(stack, "EmailIdentityArn", { value: identityArn });
|
|
52
|
+
new CfnOutput(stack, "EmailConfigurationSetName", { value: config.configurationSet });
|
|
53
|
+
new CfnOutput(stack, "EmailEventTopicName", { value: config.eventTopicName });
|
|
54
|
+
return { PAPYRUS_EMAIL_FROM_ADDRESS: `${config.senderLocalPart}@${config.sendingDomain}`,
|
|
55
|
+
PAPYRUS_EMAIL_IDENTITY_ARN: identityArn, PAPYRUS_EMAIL_CONFIGURATION_SET: config.configurationSet,
|
|
56
|
+
PAPYRUS_EMAIL_EVENT_TOPIC_NAME: config.eventTopicName };
|
|
57
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { App } from "aws-cdk-lib";
|
|
3
|
+
import { Template } from "aws-cdk-lib/assertions";
|
|
4
|
+
import { AmplifyAppShellStack } from "./amplify-app-shell.js";
|
|
5
|
+
import { parseSiteConfig } from "./site-config.js";
|
|
6
|
+
const base = () => ({
|
|
7
|
+
siteId: "example", repository: "https://github.com/example/publication", brand: "example",
|
|
8
|
+
frontend: "markus-static", papyrusVersion: "5.32.0", autoDeploy: false,
|
|
9
|
+
cms: { cognitoDomainPrefix: "example-cms", staging: false,
|
|
10
|
+
environment: { PAPYRUS_OAUTH_REDIRECT_URLS: "http://localhost:3001/" } },
|
|
11
|
+
});
|
|
12
|
+
function synth(raw) {
|
|
13
|
+
const app = new App();
|
|
14
|
+
return Template.fromStack(new AmplifyAppShellStack(app, "EmailTest", parseSiteConfig(raw), {
|
|
15
|
+
env: { account: "123456789012", region: "us-east-1" },
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
const checks = {
|
|
19
|
+
managed() {
|
|
20
|
+
const template = synth({ ...base(), email: { mode: "managed", sendingDomain: "news.example.test" } });
|
|
21
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 1);
|
|
22
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 1);
|
|
23
|
+
template.resourceCountIs("AWS::Route53::RecordSet", 0);
|
|
24
|
+
template.resourceCountIs("AWS::SES::ReceiptRuleSet", 0);
|
|
25
|
+
template.hasResourceProperties("AWS::SES::EmailIdentity", { EmailIdentity: "news.example.test" });
|
|
26
|
+
template.hasResourceProperties("AWS::SNS::Topic", { TopicName: "papyrus-newsletter-events-example" });
|
|
27
|
+
template.hasResourceProperties("AWS::SES::ConfigurationSetEventDestination", {
|
|
28
|
+
EventDestination: { Name: "cloudwatch-metrics", MatchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
|
|
29
|
+
CloudWatchDestination: { DimensionConfigurations: [{ DimensionName: "ses:configuration-set",
|
|
30
|
+
DimensionValueSource: "MESSAGE_TAG", DefaultDimensionValue: "papyrus-example" }] } },
|
|
31
|
+
});
|
|
32
|
+
const text = JSON.stringify(template.toJSON());
|
|
33
|
+
assert.ok(text.includes("PAPYRUS_EMAIL_FROM_ADDRESS"));
|
|
34
|
+
assert.ok(text.includes("notifications@news.example.test"));
|
|
35
|
+
assert.ok(text.includes("EmailDkimRecord1Name"));
|
|
36
|
+
assert.ok(!text.includes("ses:SendEmail"), "identity setup does not grant unsolicited sending authority");
|
|
37
|
+
},
|
|
38
|
+
existing() {
|
|
39
|
+
const template = synth({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test",
|
|
40
|
+
configurationSet: "already-owned", eventTopicName: "already-owned-events" } });
|
|
41
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 0);
|
|
42
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
|
|
43
|
+
template.resourceCountIs("AWS::SNS::Topic", 0);
|
|
44
|
+
assert.ok(JSON.stringify(template.toJSON()).includes("already-owned"));
|
|
45
|
+
},
|
|
46
|
+
static() {
|
|
47
|
+
const raw = { ...base(), reader: { buildCommand: "make site", baseDirectory: "dist" } };
|
|
48
|
+
delete raw.cms;
|
|
49
|
+
const template = synth(raw);
|
|
50
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 0);
|
|
51
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
|
|
52
|
+
template.resourceCountIs("AWS::SES::ReceiptRule", 0);
|
|
53
|
+
},
|
|
54
|
+
missing() {
|
|
55
|
+
assert.throws(() => parseSiteConfig(base()), /email.*required.*cloud/i);
|
|
56
|
+
assert.throws(() => parseSiteConfig({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test" } }), /email.configurationSet/);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
export function runCloudEmailTests(selected) {
|
|
60
|
+
if (selected) {
|
|
61
|
+
assert.ok(checks[selected], selected);
|
|
62
|
+
checks[selected]();
|
|
63
|
+
}
|
|
64
|
+
else
|
|
65
|
+
for (const check of Object.values(checks))
|
|
66
|
+
check();
|
|
67
|
+
console.log("cloud email infrastructure: ok");
|
|
68
|
+
}
|
|
69
|
+
runCloudEmailTests(process.argv[2]);
|