@anthusai/papyrus 2.0.0-next.5 → 2.0.0-next.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/amplify/data/resource.js +23 -3
  2. package/amplify/data/resource.ts +25 -3
  3. package/amplify/data/schema.js +2028 -1979
  4. package/amplify/data/schema.ts +72 -14
  5. package/amplify/functions/assignment-action/data-access.js +90 -0
  6. package/amplify/functions/assignment-action/data-access.ts +89 -0
  7. package/amplify/functions/assignment-action/handler.ts +1 -1
  8. package/amplify/functions/content-actions/handler.py +53 -2
  9. package/amplify/functions/content-actions/requirements.txt +1 -0
  10. package/amplify/functions/content-actions/resource.js +1 -1
  11. package/amplify/functions/content-actions/resource.ts +1 -1
  12. package/amplify/functions/editorial-checks/handler.py +14 -0
  13. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  14. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
  15. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
  16. package/amplify/functions/editorial-checks/schema.js +121 -0
  17. package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
  18. package/amplify/functions/editorial-checks/wire.js +53 -0
  19. package/amplify/functions/editorial-checks/wire.ts +61 -0
  20. package/amplify/functions/monitoring-view/handler.py +11 -0
  21. package/amplify/functions/monitoring-view/resource.js +24 -0
  22. package/amplify/functions/monitoring-view/resource.ts +34 -0
  23. package/amplify/functions/monitoring-view/schema.js +16 -0
  24. package/amplify/functions/monitoring-view/schema.ts +18 -0
  25. package/amplify/functions/monitoring-view/wire.js +37 -0
  26. package/amplify/functions/monitoring-view/wire.ts +47 -0
  27. package/amplify/functions/public-forms/core.js +247 -0
  28. package/amplify/functions/public-forms/core.ts +310 -0
  29. package/amplify/functions/public-forms/handler.ts +41 -0
  30. package/amplify/functions/public-forms/origins.js +35 -0
  31. package/amplify/functions/public-forms/origins.ts +43 -0
  32. package/amplify/functions/public-forms/resource.js +9 -0
  33. package/amplify/functions/public-forms/resource.ts +10 -0
  34. package/amplify/functions/public-forms/wire.js +25 -0
  35. package/amplify/functions/public-forms/wire.ts +32 -0
  36. package/amplify/functions/shared/editorial-context.ts +63 -4
  37. package/amplify/functions/shared/python-bundle.js +20 -14
  38. package/amplify/functions/shared/python-bundle.ts +25 -18
  39. package/amplify/plugins/newsletter/config.js +195 -0
  40. package/amplify/plugins/newsletter/config.ts +240 -0
  41. package/amplify/plugins/newsletter/function/handler.py +6 -0
  42. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  43. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  44. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  45. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  46. package/amplify/plugins/newsletter/papyrus.plugin.js +260 -0
  47. package/amplify/plugins/newsletter/papyrus.plugin.ts +308 -0
  48. package/amplify/plugins/newsletter/schema.js +56 -0
  49. package/amplify/plugins/newsletter/schema.ts +61 -0
  50. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  51. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  52. package/amplify/plugins/newsletter/sending.js +140 -0
  53. package/amplify/plugins/newsletter/sending.ts +186 -0
  54. package/amplify/plugins/reader-steering/config.js +162 -0
  55. package/amplify/plugins/reader-steering/config.ts +248 -0
  56. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  57. package/amplify/plugins/reader-steering/function/handler.py +1363 -0
  58. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  59. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  60. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  61. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  62. package/amplify/plugins/reader-steering/papyrus.plugin.js +175 -0
  63. package/amplify/plugins/reader-steering/papyrus.plugin.ts +201 -0
  64. package/amplify/plugins/reader-steering/schema.js +140 -0
  65. package/amplify/plugins/reader-steering/schema.ts +142 -0
  66. package/amplify/plugins/registry.js +4 -2
  67. package/amplify/plugins/registry.ts +4 -2
  68. package/amplify/plugins/types.ts +5 -0
  69. package/amplify/plugins/wire.js +1 -0
  70. package/amplify/plugins/wire.ts +1 -0
  71. package/amplify/publication/redirect-and-index.js +105 -0
  72. package/amplify/publication/redirect-rules.js +73 -0
  73. package/amplify/publication/redirect-rules.ts +93 -0
  74. package/amplify/publication/s3-projection.js +7 -1
  75. package/amplify/publication/s3-projection.ts +9 -1
  76. package/amplify/site-backend-config.ts +17 -0
  77. package/amplify/site-backend.js +36 -1
  78. package/amplify/site-backend.ts +44 -1
  79. package/app/api/newsletter/confirm/route.ts +13 -0
  80. package/app/api/newsletter/preferences/route.ts +13 -0
  81. package/app/api/newsletter/subscribe/route.ts +9 -0
  82. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  83. package/app/api/newsroom/curation/context/route.ts +9 -0
  84. package/app/api/newsroom/curation/priming/route.ts +9 -0
  85. package/app/api/newsroom/curation/publication/route.ts +9 -0
  86. package/app/api/reader/comment/delete/route.ts +9 -0
  87. package/app/api/reader/comment/edit/route.ts +9 -0
  88. package/app/api/reader/comment/route.ts +9 -0
  89. package/app/api/reader/handle/route.ts +9 -0
  90. package/app/api/reader/me/route.ts +9 -0
  91. package/app/api/reader/rating/route.ts +9 -0
  92. package/app/api/reader/report/route.ts +9 -0
  93. package/app/api/reader/retired/route.ts +9 -0
  94. package/app/api/reader/submit/route.ts +9 -0
  95. package/app/api/reader/vote/route.ts +9 -0
  96. package/bin/papyrus-infra.mjs +20 -1
  97. package/components/editorial-checks-admin-panel.tsx +36 -11
  98. package/components/moderation-panel.tsx +248 -0
  99. package/components/monitoring-overview-view.tsx +241 -0
  100. package/components/monitoring-panel.tsx +40 -0
  101. package/components/newsletter-panel.tsx +474 -0
  102. package/components/newsletter-sections.tsx +163 -0
  103. package/components/newsroom-article-editor.tsx +2 -4
  104. package/components/newsroom-board-view.tsx +4 -1
  105. package/components/newsroom-curation-bootstrap.tsx +45 -0
  106. package/components/newsroom-organizer.css +31 -0
  107. package/components/newsroom-references-view.tsx +14 -14
  108. package/components/publication-cyclotron-panel.tsx +15 -10
  109. package/components/reference-add-entry.tsx +75 -0
  110. package/components/reference-curation-controls.tsx +31 -22
  111. package/components/reference-cyclotron-priming.tsx +58 -19
  112. package/components/reference-cyclotron-suggestion.tsx +1 -1
  113. package/components/reference-priming.tsx +14 -10
  114. package/components/reference-section-suggestion.tsx +1 -1
  115. package/components/topic-steering-workspace.tsx +64 -20
  116. package/components/use-editorial-checks.ts +12 -10
  117. package/infra/amplify-app-shell.js +90 -16
  118. package/infra/amplify-app-shell.ts +102 -18
  119. package/infra/index.js +1 -0
  120. package/infra/secure-string-parameter-handler.js +41 -0
  121. package/infra/secure-string-parameter-handler.ts +41 -0
  122. package/infra/secure-string-parameter.js +79 -0
  123. package/infra/secure-string-parameter.ts +91 -0
  124. package/infra/site-config.js +85 -6
  125. package/infra/site-config.ts +123 -6
  126. package/lib/category-repository.ts +11 -9
  127. package/lib/curation-routes.ts +345 -0
  128. package/lib/editorial-checks-backend.ts +1 -1
  129. package/lib/editorial-checks-client.ts +19 -1
  130. package/lib/editorial-checks-demo.ts +2 -2
  131. package/lib/editorial-checks-settings.ts +3 -0
  132. package/lib/editorial-checks.ts +8 -11
  133. package/lib/editorial-preview.ts +5 -5
  134. package/lib/monitoring-client.ts +29 -0
  135. package/lib/monitoring-overview-fixture.ts +51 -0
  136. package/lib/monitoring-overview.ts +145 -0
  137. package/lib/newsletter-api.ts +435 -0
  138. package/lib/newsletter-client.ts +46 -0
  139. package/lib/newsletter-fixture.ts +184 -0
  140. package/lib/newsletter-model.ts +202 -0
  141. package/lib/newsletter-pages.ts +364 -0
  142. package/lib/newsroom-board.ts +27 -2
  143. package/lib/prospectus-session.ts +15 -14
  144. package/lib/prospectus-sign-in-routes.ts +54 -13
  145. package/lib/prospectus-site-identity.ts +24 -1
  146. package/lib/reader-api.ts +145 -0
  147. package/lib/reader-moderation.ts +220 -0
  148. package/lib/reader-retirement-route.ts +89 -0
  149. package/lib/reader-subject.md +29 -0
  150. package/lib/reader-subject.ts +53 -0
  151. package/lib/relevance-status.ts +2 -2
  152. package/lib/site-brand-tenant.ts +11 -6
  153. package/lib/site-brand.ts +6 -3
  154. package/lib/site-origin.ts +67 -0
  155. package/middleware.ts +14 -3
  156. package/package.json +42 -4
  157. package/routes.manifest.json +191 -1
  158. package/amplify/plugins/editorial-checks/function/handler.py +0 -11
  159. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  160. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -50
  161. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -63
  162. package/amplify/plugins/editorial-checks/schema.js +0 -94
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useId, useRef, useState, type KeyboardEvent } from "react";
4
4
  import { primingStateText, type PrimingRequestView } from "../lib/relevance-status";
5
5
 
6
6
  export type PrimingFormInput = { url: string; label: string; explanation: string };
7
7
 
8
8
  /**
9
- * A compact "Add a reference" form for a pasted URL. The page is read in the
9
+ * The "Paste a URL" option of the Add a reference area. The page is read in the
10
10
  * background and the outcome is shown beside each added reference.
11
11
  */
12
12
  export function ReferenceCyclotronPriming({
@@ -23,6 +23,19 @@ export function ReferenceCyclotronPriming({
23
23
  const [explanation, setExplanation] = useState("");
24
24
  const [message, setMessage] = useState<string | null>(null);
25
25
 
26
+ const ratingLabelId = useId();
27
+ const buttons = useRef<Array<HTMLButtonElement | null>>([]);
28
+
29
+ function moveSelection(event: KeyboardEvent<HTMLButtonElement>, index: number) {
30
+ const forward = event.key === "ArrowRight" || event.key === "ArrowDown";
31
+ const backward = event.key === "ArrowLeft" || event.key === "ArrowUp";
32
+ if (!forward && !backward) return;
33
+ event.preventDefault();
34
+ const next = (index + (forward ? 1 : -1) + labels.length) % labels.length;
35
+ setLabel(labels[next]);
36
+ buttons.current[next]?.focus();
37
+ }
38
+
26
39
  async function submit() {
27
40
  setMessage("Adding…");
28
41
  try {
@@ -36,16 +49,16 @@ export function ReferenceCyclotronPriming({
36
49
  }
37
50
 
38
51
  return (
39
- <section aria-label="Add a reference" className="space-y-2 text-sm" data-reference-cyclotron-priming>
52
+ <div className="space-y-2 text-sm" data-reference-cyclotron-priming>
40
53
  <form
41
- className="flex flex-wrap items-end gap-3"
54
+ className="flex flex-col gap-3"
42
55
  onSubmit={(event) => {
43
56
  event.preventDefault();
44
57
  void submit();
45
58
  }}
46
59
  >
47
- <label className="flex min-w-64 flex-1 flex-col gap-1">
48
- <span className="text-xs text-muted-foreground">Add a reference by URL</span>
60
+ <label className="flex flex-col gap-1">
61
+ <span className="text-xs text-muted-foreground">Web page address</span>
49
62
  <input
50
63
  aria-label="Reference URL"
51
64
  onChange={(event) => setUrl(event.target.value)}
@@ -55,20 +68,46 @@ export function ReferenceCyclotronPriming({
55
68
  value={url}
56
69
  />
57
70
  </label>
71
+ <div className="flex flex-col gap-1">
72
+ <span className="text-xs text-muted-foreground" id={ratingLabelId}>Should Papyrus include or exclude pages like this?</span>
73
+ <div aria-labelledby={ratingLabelId} className="flex flex-wrap gap-2" role="radiogroup">
74
+ {labels.map((choice, index) => {
75
+ const checked = choice === label;
76
+ return (
77
+ <button
78
+ aria-checked={checked}
79
+ className={`min-w-32 flex-1 rounded border px-4 py-3 text-base font-semibold capitalize ${checked ? "border-foreground bg-foreground text-background" : "border-border bg-transparent"}`}
80
+ data-rating-choice={choice}
81
+ key={choice}
82
+ onClick={() => setLabel(choice)}
83
+ onKeyDown={(event) => moveSelection(event, index)}
84
+ ref={(node) => { buttons.current[index] = node; }}
85
+ role="radio"
86
+ tabIndex={checked || (!labels.includes(label) && index === 0) ? 0 : -1}
87
+ type="button"
88
+ >
89
+ {choice}
90
+ </button>
91
+ );
92
+ })}
93
+ </div>
94
+ </div>
58
95
  <label className="flex flex-col gap-1">
59
- <span className="text-xs text-muted-foreground">Include or exclude</span>
60
- <select aria-label="Include or exclude" onChange={(event) => setLabel(event.target.value)} value={label}>
61
- {labels.map((choice) => (
62
- <option key={choice} value={choice}>{choice}</option>
63
- ))}
64
- </select>
65
- </label>
66
- <label className="flex min-w-48 flex-1 flex-col gap-1">
67
- <span className="text-xs text-muted-foreground">Why (optional)</span>
68
- <input aria-label="Reason" onChange={(event) => setExplanation(event.target.value)} type="text" value={explanation} />
96
+ <span className="text-sm font-medium">Why?</span>
97
+ <textarea
98
+ aria-label="Reason"
99
+ className="w-full"
100
+ onChange={(event) => setExplanation(event.target.value)}
101
+ placeholder="Why? A sentence or two helps Papyrus learn what you want."
102
+ rows={4}
103
+ value={explanation}
104
+ />
105
+ <span className="text-xs text-muted-foreground">Optional, but a short reason helps Papyrus learn what you want.</span>
69
106
  </label>
70
- <button className="cyclotron-review-secondary" type="submit">Add reference</button>
71
- {message ? <p className="basis-full text-xs" role="status">{message}</p> : null}
107
+ <div>
108
+ <button className="cyclotron-review-secondary" type="submit">Add reference</button>
109
+ </div>
110
+ {message ? <p className="text-xs" role="status">{message}</p> : null}
72
111
  </form>
73
112
  {requests.length ? (
74
113
  <ul aria-label="Added references" className="space-y-1 text-xs text-muted-foreground">
@@ -79,6 +118,6 @@ export function ReferenceCyclotronPriming({
79
118
  ))}
80
119
  </ul>
81
120
  ) : null}
82
- </section>
121
+ </div>
83
122
  );
84
123
  }
@@ -60,7 +60,7 @@ export function ReferenceSuggestionBlock({
60
60
  <section aria-label="Suggestion" className="space-y-2 border-y border-foreground py-3" data-reference-suggestion={referenceLineageId}>
61
61
  <h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggestion</h3>
62
62
  <p className="text-sm text-foreground">
63
- The cyclotron suggests <span className="font-semibold">{decision.label}</span> ({suggestionConfidenceWords(decision)}).
63
+ Papyrus suggests <span className="font-semibold">{decision.label}</span> ({suggestionConfidenceWords(decision)}).
64
64
  </p>
65
65
  <p className="text-xs text-foreground" data-reference-suggestion-notice>
66
66
  A suggestion never changes the status until an editor acts. This reference is {statusLabel.toLowerCase()}.
@@ -1,18 +1,22 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
4
3
  import { PrimingPanel, type PrimingPanelProps } from "cyclotron/components/priming-panel";
5
- import { Button } from "./ui/button";
6
4
 
7
- /** Papyrus supplies its transport/configuration; Cyclotron owns preview and labels. */
5
+ /**
6
+ * The "Import a file" option of the Add a reference area. Papyrus supplies the
7
+ * transport and configuration; Cyclotron owns the preview and label controls.
8
+ * It is only rendered by `ReferenceAddEntry`, never as a section of its own.
9
+ */
8
10
  export function ReferencePriming(props: PrimingPanelProps) {
9
- const [open, setOpen] = useState(false);
10
11
  return (
11
- <section className="shrink-0 space-y-3" aria-label="Representative reference examples">
12
- <Button aria-expanded={open} onClick={() => setOpen((value) => !value)} variant="outline">
13
- {open ? "Hide example references" : "Add example references (optional)"}
14
- </Button>
15
- {open ? <div className="max-h-[55dvh] space-y-3 overflow-y-auto pr-1"><p className="text-sm text-muted-foreground">Corrections to extracted content: describe what needs fixing, then enter the corrected title and abstract or summary. The note is saved with your correction; it does not automatically regenerate the content. Choose Publish / Not publish separately. An explanation of that judgment is optional.</p><PrimingPanel {...props} renderItem={props.renderItem ?? ((item) => <div className="space-y-2"><p className="whitespace-pre-wrap text-sm">{String(item.values.abstract ?? item.values.text ?? "")}</p><p className="break-all text-xs text-muted-foreground">{String(item.values.source_uri ?? "")}</p></div>)} /></div> : null}
16
- </section>
12
+ <PrimingPanel
13
+ {...props}
14
+ renderItem={props.renderItem ?? ((item) => (
15
+ <div className="space-y-2">
16
+ <p className="whitespace-pre-wrap text-sm">{String(item.values.abstract ?? item.values.text ?? "")}</p>
17
+ <p className="break-all text-xs text-muted-foreground">{String(item.values.source_uri ?? "")}</p>
18
+ </div>
19
+ ))}
20
+ />
17
21
  );
18
22
  }
@@ -40,7 +40,7 @@ export function SectionSuggestionBlock({
40
40
  <section aria-label="Suggested Section" className="space-y-2 border-y border-foreground py-3" data-reference-section-suggestion={referenceLineageId}>
41
41
  <h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggested Section</h3>
42
42
  <p className="text-sm text-foreground">
43
- The cyclotron suggests <span className="font-semibold">{titleOf(sections, suggestion.sectionId)}</span>
43
+ Papyrus suggests <span className="font-semibold">{titleOf(sections, suggestion.sectionId)}</span>
44
44
  {suggestion.confidence !== null ? ` (${formatPercentage(suggestion.confidence)})` : ""}.
45
45
  </p>
46
46
  {others.length ? (
@@ -14,6 +14,9 @@ import type { ReactNode, RefObject } from "react";
14
14
  import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useTransition } from "react";
15
15
  import amplifyOutputs from "papyrus-amplify-outputs";
16
16
  import { EditorialChecksAdminPanel } from "./editorial-checks-admin-panel";
17
+ import { MonitoringPanel } from "./monitoring-panel";
18
+ import { ModerationPanel } from "./moderation-panel";
19
+ import { NewsletterPanel } from "./newsletter-panel";
17
20
  import { SignupEntriesPanel } from "./signup-entries-panel";
18
21
  import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
19
22
  import { generateClient } from "aws-amplify/data";
@@ -89,6 +92,7 @@ import { ReferenceSourcePreview } from "./reference-source-preview";
89
92
  import type { ReaderAuthSnapshot } from "./reader-auth-state";
90
93
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
91
94
  import { NewsroomOpsOverview } from "./newsroom-ops-overview";
95
+ import { NewsroomCurationBootstrap } from "./newsroom-curation-bootstrap";
92
96
  import { NewsroomReferencesView } from "./newsroom-references-view";
93
97
  import { NewsroomAssignmentsView } from "./newsroom-assignments-view";
94
98
  import { NewsroomTopicsView } from "./newsroom-topics-view";
@@ -247,7 +251,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
247
251
  type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
248
252
  type AssignmentDeskViewMode = "queue" | "budget";
249
253
  type UserRoleAction = "grant" | "revoke";
250
- type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "editorial-checks";
254
+ type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "newsletter" | "moderation" | "editorial-checks" | "monitoring";
251
255
  export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
252
256
  type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
253
257
  type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
@@ -879,7 +883,7 @@ function NewsDeskDashboard({
879
883
  const [mergeSelection, setMergeSelection] = useState<MergeSelection | null>(null);
880
884
  const [isPending, startTransition] = useTransition();
881
885
  const controlsDisabled = isPending || !canEdit;
882
- const initialAdministrationPanel = normalizeAdministrationPanel(initialSelection.panel);
886
+ const initialAdministrationPanel = normalizeAdministrationPanel(initialSelection.panel, Boolean(dashboard.isDemo));
883
887
  const [administrationPanel, setAdministrationPanel] = useState<AdministrationPanel>(initialAdministrationPanel);
884
888
  const [doctrineDrafts, setDoctrineDrafts] = useState<DoctrineEditorState>(() => buildDoctrineEditorState(dashboard.doctrineRecords));
885
889
  const [loadedSections, setLoadedSections] = useState<Record<string, boolean>>({
@@ -1120,8 +1124,8 @@ function NewsDeskDashboard({
1120
1124
  }, [dashboard.procedureDefinitions, dashboard.procedureRuns, dashboard.procedureVersions]);
1121
1125
 
1122
1126
  useEffect(() => {
1123
- setAdministrationPanel(normalizeAdministrationPanel(initialSelection.panel));
1124
- }, [initialSelection.panel]);
1127
+ setAdministrationPanel(normalizeAdministrationPanel(initialSelection.panel, Boolean(dashboard.isDemo)));
1128
+ }, [initialSelection.panel, dashboard.isDemo]);
1125
1129
 
1126
1130
  useEffect(() => {
1127
1131
  if (activeTab !== "administration") return;
@@ -3115,6 +3119,7 @@ function NewsDeskDashboard({
3115
3119
  />
3116
3120
  ) : null}
3117
3121
  {activeTab === "references" ? (
3122
+ <NewsroomCurationBootstrap enabled={!dashboard.isDemo}>
3118
3123
  <NewsroomReferencesView
3119
3124
  demo={Boolean(dashboard.isDemo)}
3120
3125
  disabled={controlsDisabled}
@@ -3152,6 +3157,7 @@ function NewsDeskDashboard({
3152
3157
  referenceAttachments={referenceAttachments}
3153
3158
  references={references}
3154
3159
  />
3160
+ </NewsroomCurationBootstrap>
3155
3161
  ) : null}
3156
3162
  {activeTab === "messages" ? (
3157
3163
  <MessagesDeskView
@@ -4318,17 +4324,24 @@ function AdministrationDeskView({
4318
4324
  <strong>Agent delegations</strong>
4319
4325
  <span>Let an agent curate References for you</span>
4320
4326
  </Link>
4321
- {editorialChecksPluginEnabled() ? (
4322
- <Link
4323
- href={administrationPanelHref("editorial-checks", isDemo)}
4324
- data-news-desk-admin-nav="editorial-checks"
4325
- data-active={administrationPanel === "editorial-checks" || undefined}
4326
- onClick={() => onPanelChange("editorial-checks")}
4327
- >
4328
- <strong>Editorial checks</strong>
4329
- <span>Policies the article editor checks drafts against</span>
4330
- </Link>
4331
- ) : null}
4327
+ <Link
4328
+ href={administrationPanelHref("editorial-checks", isDemo)}
4329
+ data-news-desk-admin-nav="editorial-checks"
4330
+ data-active={administrationPanel === "editorial-checks" || undefined}
4331
+ onClick={() => onPanelChange("editorial-checks")}
4332
+ >
4333
+ <strong>Editorial checks</strong>
4334
+ <span>Policies the article editor checks drafts against</span>
4335
+ </Link>
4336
+ <Link
4337
+ href={administrationPanelHref("monitoring", isDemo)}
4338
+ data-news-desk-admin-nav="monitoring"
4339
+ data-active={administrationPanel === "monitoring" || undefined}
4340
+ onClick={() => onPanelChange("monitoring")}
4341
+ >
4342
+ <strong>Monitoring</strong>
4343
+ <span>The automated research pipeline, read only</span>
4344
+ </Link>
4332
4345
  {signupPluginEnabled() ? (
4333
4346
  <Link
4334
4347
  href={administrationPanelHref("signups", isDemo)}
@@ -4340,10 +4353,35 @@ function AdministrationDeskView({
4340
4353
  <span>Addresses from the site&apos;s signup forms</span>
4341
4354
  </Link>
4342
4355
  ) : null}
4356
+ {newsletterPluginEnabled(isDemo) ? (
4357
+ <Link
4358
+ href={administrationPanelHref("newsletter", isDemo)}
4359
+ data-news-desk-admin-nav="newsletter"
4360
+ data-active={administrationPanel === "newsletter" || undefined}
4361
+ onClick={() => onPanelChange("newsletter")}
4362
+ >
4363
+ <strong>Newsletter</strong>
4364
+ <span>Subscribers, issues and delivery for the site&apos;s email</span>
4365
+ </Link>
4366
+ ) : null}
4367
+ {readerPluginEnabled() ? (
4368
+ <Link
4369
+ href={administrationPanelHref("moderation", isDemo)}
4370
+ data-news-desk-admin-nav="moderation"
4371
+ data-active={administrationPanel === "moderation" || undefined}
4372
+ onClick={() => onPanelChange("moderation")}
4373
+ >
4374
+ <strong>Moderation</strong>
4375
+ <span>Reader submissions and comments</span>
4376
+ </Link>
4377
+ ) : null}
4343
4378
  </nav>}
4344
4379
  <div className="news-desk-settings-panel">
4345
4380
  {administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
4381
+ {administrationPanel === "newsletter" ? <NewsletterPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
4382
+ {administrationPanel === "moderation" ? <ModerationPanel /> : null}
4346
4383
  {administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
4384
+ {administrationPanel === "monitoring" ? <MonitoringPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
4347
4385
  {administrationPanel === "delegations" ? <AdministrationDelegationsPanel disabled={disabled} /> : null}
4348
4386
  {administrationPanel === "users" ? (
4349
4387
  <AdministrationUsersPanel
@@ -12807,14 +12845,20 @@ function signupPluginEnabled(): boolean {
12807
12845
  return pluginSettings(amplifyOutputs as PluginOutputs, "signup") !== null;
12808
12846
  }
12809
12847
 
12810
- function editorialChecksPluginEnabled(): boolean {
12811
- return pluginSettings(amplifyOutputs as PluginOutputs, "editorial-checks") !== null;
12848
+ /** The Newsletter panel exists when the site enabled the plugin; `?demo=1` shows it with fixture data so it can be seen and tested. */
12849
+ function newsletterPluginEnabled(demo = false): boolean {
12850
+ return demo || pluginSettings(amplifyOutputs as PluginOutputs, "newsletter") !== null;
12851
+ }
12852
+
12853
+ function readerPluginEnabled(): boolean {
12854
+ return pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering") !== null;
12812
12855
  }
12813
12856
 
12814
- function normalizeAdministrationPanel(value: string | null | undefined): AdministrationPanel {
12815
- if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations") return value;
12857
+ function normalizeAdministrationPanel(value: string | null | undefined, demo = false): AdministrationPanel {
12858
+ if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
12816
12859
  if (value === "signups" && signupPluginEnabled()) return value;
12817
- if (value === "editorial-checks" && editorialChecksPluginEnabled()) return value;
12860
+ if (value === "newsletter" && newsletterPluginEnabled(demo)) return value;
12861
+ if (value === "moderation" && readerPluginEnabled()) return value;
12818
12862
  return "users";
12819
12863
  }
12820
12864
 
@@ -27,14 +27,16 @@ export function useEditorialChecks({
27
27
  section,
28
28
  itemId,
29
29
  }: {
30
- /** null turns editorial checks off. */
30
+ /** null when there is no backend to check against (a preview). Whether a publication runs checks is its stored setting. */
31
31
  backend: EditorialChecksBackend | null;
32
32
  body: string | null;
33
33
  section: string;
34
34
  itemId: string | null;
35
35
  }) {
36
- const enabled = backend !== null;
36
+ const available = backend !== null;
37
37
  const [settings, setSettings] = useState<EditorialSettings>(DEFAULT_EDITORIAL_SETTINGS);
38
+ const [settingsLoaded, setSettingsLoaded] = useState(false);
39
+ const enabled = available && settings.enabled;
38
40
  const [result, setResult] = useState<EditorialCheckResult | null>(null);
39
41
  const [checkedBody, setCheckedBody] = useState<string | null>(null);
40
42
  const [status, setStatus] = useState<CheckStatus>({ state: "idle" });
@@ -46,9 +48,9 @@ export function useEditorialChecks({
46
48
  const sequence = useRef(0);
47
49
 
48
50
  useEffect(() => {
49
- if (!enabled) return;
50
- backend?.loadSettings().then(({ settings: loaded }) => setSettings(loaded)).catch(() => undefined);
51
- }, [backend, enabled]);
51
+ if (!available) return;
52
+ backend?.loadSettings().then(({ settings: loaded }) => setSettings(loaded)).catch(() => undefined).finally(() => setSettingsLoaded(true));
53
+ }, [available, backend]);
52
54
 
53
55
  useEffect(() => {
54
56
  if (!enabled || !itemId) return;
@@ -57,7 +59,7 @@ export function useEditorialChecks({
57
59
 
58
60
  const check = useCallback(async () => {
59
61
  const text = latestBody.current;
60
- if (!backend || text === null || !text.trim()) return;
62
+ if (!backend || !enabled || text === null || !text.trim()) return;
61
63
  const run = ++sequence.current;
62
64
  setStatus({ state: "checking" });
63
65
  try {
@@ -73,13 +75,13 @@ export function useEditorialChecks({
73
75
  } catch (error) {
74
76
  if (run === sequence.current) setStatus({ state: "error", message: error instanceof Error ? error.message : "The check failed." });
75
77
  }
76
- }, [backend, section]);
78
+ }, [backend, enabled, section]);
77
79
 
78
80
  useEffect(() => {
79
- if (!enabled || !settings.checkWhileTyping || body === null || body === checkedBody) return;
81
+ if (!enabled || !settingsLoaded || !settings.checkWhileTyping || body === null || body === checkedBody) return;
80
82
  const timer = window.setTimeout(() => void check(), CHECK_PAUSE_MS);
81
83
  return () => window.clearTimeout(timer);
82
- }, [body, check, checkedBody, enabled, settings.checkWhileTyping]);
84
+ }, [body, check, checkedBody, enabled, settings.checkWhileTyping, settingsLoaded]);
83
85
 
84
86
  const record = useCallback(
85
87
  (finding: EditorialFinding, decision: FeedbackDecision, reason: string | null = null) => {
@@ -105,7 +107,7 @@ export function useEditorialChecks({
105
107
  [backend, section],
106
108
  );
107
109
 
108
- const current = result && checkedBody === body;
110
+ const current = enabled && result && checkedBody === body;
109
111
  return {
110
112
  enabled,
111
113
  settings,
@@ -1,10 +1,10 @@
1
1
  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
- import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
4
+ import { SecureStringParameter } from "./secure-string-parameter.js";
5
5
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
6
6
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
7
- import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
7
+ import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
8
8
  function environmentVariables(variables) {
9
9
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
10
10
  }
@@ -68,6 +68,9 @@ export class AmplifyAppShellStack extends Stack {
68
68
  if (!config.reader)
69
69
  throw new Error(`site ${config.siteId} has neither a cms section nor a reader`);
70
70
  this.readerAppId = this.addReaderApp(config, config.reader);
71
+ if (config.autoDeploy) {
72
+ this.addGithubDeployRole(config, [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }]);
73
+ }
71
74
  return;
72
75
  }
73
76
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
@@ -89,33 +92,49 @@ export class AmplifyAppShellStack extends Stack {
89
92
  });
90
93
  const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
91
94
  if (revalidateSecretParameterName) {
95
+ const revalidateSecret = new SecureStringParameter(this, "RevalidateSecretParameter", {
96
+ parameterName: revalidateSecretParameterName,
97
+ description: `Shared secret that authorizes publishes to call the /api/revalidate route of ${cmsAppName}`,
98
+ });
92
99
  computeRole.addToPolicy(new iam.PolicyStatement({
93
100
  sid: "ReadRevalidateSecret",
94
101
  actions: ["ssm:GetParameter"],
95
- resources: [
96
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
97
- ],
102
+ resources: [revalidateSecret.parameterArn],
98
103
  }));
99
104
  }
100
105
  const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
101
- const prospectusSessionSealingSecret = prospectusAccountsApiStageArn
102
- ? new secretsmanager.Secret(this, "ProspectusSessionSealingSecret", {
106
+ const prospectusSessionSealing = prospectusAccountsApiStageArn
107
+ ? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
108
+ parameterName: resolveProspectusSessionSealingParameterName(config),
103
109
  description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
104
- generateSecretString: { passwordLength: 64, excludePunctuation: true },
105
110
  })
106
111
  : undefined;
107
- const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealingSecret
112
+ const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealing
108
113
  ? {
109
114
  PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
110
- PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN: prospectusSessionSealingSecret.secretArn,
115
+ PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
111
116
  PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
112
117
  }
113
118
  : {};
114
- if (prospectusSessionSealingSecret) {
119
+ if (prospectusSessionSealing) {
115
120
  computeRole.addToPolicy(new iam.PolicyStatement({
116
121
  sid: "ReadProspectusSessionSealingSecret",
117
- actions: ["secretsmanager:GetSecretValue"],
118
- resources: [prospectusSessionSealingSecret.secretArn],
122
+ actions: ["ssm:GetParameter"],
123
+ resources: [prospectusSessionSealing.parameterArn],
124
+ }));
125
+ }
126
+ if (prospectusAccountsApiStageArn && config.cms.readerRetirementPoll) {
127
+ // Prospectus admits one caller per site, this compute role, so the scheduled snapshot run reaches the retired-subject
128
+ // route through the CMS route /api/reader/retired, which it authenticates with this secret.
129
+ const readerPollSecret = new SecureStringParameter(this, "ReaderPollSecretParameter", {
130
+ parameterName: resolveReaderPollSecretParameterName(config),
131
+ description: `Authorizes the scheduled reader snapshot run to call the retired-subject route of ${cmsAppName}`,
132
+ });
133
+ prospectusEnvironment.PAPYRUS_READER_POLL_SECRET_PARAMETER = readerPollSecret.parameterName;
134
+ computeRole.addToPolicy(new iam.PolicyStatement({
135
+ sid: "ReadReaderPollSecret",
136
+ actions: ["ssm:GetParameter"],
137
+ resources: [readerPollSecret.parameterArn],
119
138
  }));
120
139
  }
121
140
  if (prospectusAccountsApiStageArn) {
@@ -153,9 +172,15 @@ export class AmplifyAppShellStack extends Stack {
153
172
  const stagingOrigin = stagingDomainName
154
173
  ? `https://${stagingDomainName}/`
155
174
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
175
+ // The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
176
+ // A site served by its own edge (cms.siteOrigin) has no Amplify domain, so the edge host is its origin.
177
+ const siteOrigin = resolveSiteOrigin(config);
178
+ const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : siteOrigin ?? Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
179
+ const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
156
180
  const sharedRootDomain = config.cms.domainPrefix !== undefined;
157
181
  const defaultOriginsToAllow = [
158
182
  ...(config.cms.domainName ? [] : [productionOrigin]),
183
+ ...(siteOrigin ? [`${siteOrigin}/`] : []),
159
184
  ...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
160
185
  ];
161
186
  const oauthRedirectUrls = defaultOriginsToAllow.length === 0
@@ -165,7 +190,8 @@ export class AmplifyAppShellStack extends Stack {
165
190
  appId: cmsApp.attrAppId,
166
191
  branchName: "main",
167
192
  stage: "PRODUCTION",
168
- enableAutoBuild: true,
193
+ // Auto-build off when GitHub Actions starts the job on merge (autoDeploy): nothing may double-build.
194
+ enableAutoBuild: !config.autoDeploy,
169
195
  enablePerformanceMode: false,
170
196
  environmentVariables: environmentVariables({
171
197
  ...config.cms.environment,
@@ -174,6 +200,7 @@ export class AmplifyAppShellStack extends Stack {
174
200
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
175
201
  PAPYRUS_SITE_BRAND: config.brand,
176
202
  SITE_ENV: "production",
203
+ PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
177
204
  PAPYRUS_CONTENT_SOURCE: "published",
178
205
  ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
179
206
  ...prospectusEnvironment,
@@ -195,6 +222,7 @@ export class AmplifyAppShellStack extends Stack {
195
222
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
196
223
  PAPYRUS_SITE_BRAND: config.brand,
197
224
  SITE_ENV: "staging",
225
+ PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
198
226
  ...stagingMode,
199
227
  ...prospectusEnvironment,
200
228
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
@@ -247,6 +275,12 @@ export class AmplifyAppShellStack extends Stack {
247
275
  }
248
276
  const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
249
277
  this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
278
+ if (config.autoDeploy) {
279
+ this.addGithubDeployRole(config, [
280
+ { appId: cmsApp.attrAppId, branch: "main" },
281
+ ...(this.readerAppId && config.reader ? [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }] : []),
282
+ ]);
283
+ }
250
284
  if (config.github) {
251
285
  this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
252
286
  }
@@ -294,6 +328,46 @@ export class AmplifyAppShellStack extends Stack {
294
328
  description: `Role the Papyrus CLI assumes for ${config.siteId} (profile role_arn)`,
295
329
  });
296
330
  }
331
+ /**
332
+ * The merge-to-main deploy role: assumable only by a GitHub Actions run on `main` of the site's own
333
+ * repository (OIDC, no keys), and able to do exactly one thing: start and read Amplify jobs on the
334
+ * production branch of the site's apps.
335
+ */
336
+ addGithubDeployRole(config, targets) {
337
+ const { owner, repo } = resolveGithubRepository(config);
338
+ const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubDeployOidcProvider", this.formatArn({
339
+ service: "iam",
340
+ region: "",
341
+ resource: "oidc-provider",
342
+ resourceName: GITHUB_OIDC_PROVIDER_HOST,
343
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
344
+ }));
345
+ const deployRole = new iam.Role(this, "GithubDeployRole", {
346
+ roleName: `${config.siteId}-github-deploy`,
347
+ description: `Merge-to-main deploy for ${owner}/${repo}: GitHub OIDC on refs/heads/main only; amplify:StartJob and GetJob on the production branch of the site's apps only`,
348
+ maxSessionDuration: Duration.hours(1),
349
+ assumedBy: new iam.OpenIdConnectPrincipal(provider, {
350
+ StringEquals: {
351
+ [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com",
352
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: githubOidcSubjects(config, "main"),
353
+ },
354
+ }),
355
+ });
356
+ deployRole.addToPolicy(new iam.PolicyStatement({
357
+ sid: "StartAndReadProductionJobs",
358
+ actions: ["amplify:StartJob", "amplify:GetJob"],
359
+ resources: targets.map((target) => this.formatArn({
360
+ service: "amplify",
361
+ resource: "apps",
362
+ resourceName: `${target.appId}/branches/${target.branch}/jobs/*`,
363
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
364
+ })),
365
+ }));
366
+ new CfnOutput(this, "GithubDeployRoleArn", {
367
+ value: deployRole.roleArn,
368
+ description: `Role for the merge-to-main deploy workflow in ${owner}/${repo}`,
369
+ });
370
+ }
297
371
  addGithubCiRole(config, github, cmsAppId, amplifyAppIds) {
298
372
  const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubOidcProvider", this.formatArn({
299
373
  service: "iam",
@@ -309,7 +383,7 @@ export class AmplifyAppShellStack extends Stack {
309
383
  assumedBy: new iam.OpenIdConnectPrincipal(provider, {
310
384
  StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
311
385
  StringLike: {
312
- [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.map((branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`),
386
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.flatMap((branch) => githubOidcSubjects(config, branch)),
313
387
  },
314
388
  }),
315
389
  });
@@ -361,7 +435,7 @@ export class AmplifyAppShellStack extends Stack {
361
435
  appId: readerApp.attrAppId,
362
436
  branchName: readerBranchName,
363
437
  stage: "PRODUCTION",
364
- enableAutoBuild: true,
438
+ enableAutoBuild: !config.autoDeploy,
365
439
  environmentVariables: environmentVariables({
366
440
  ...(reader.environment ?? {}),
367
441
  PAPYRUS_SITE_BRAND: config.brand,