@anthusai/papyrus 5.43.0 → 5.44.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.
Files changed (43) hide show
  1. package/amplify/functions/category-action/handler.ts +2 -1
  2. package/amplify/functions/monitoring-view/schema.js +8 -0
  3. package/amplify/functions/monitoring-view/schema.ts +8 -0
  4. package/amplify/functions/procedure-action/handler.ts +2 -10
  5. package/amplify/functions/proposal-actions/proposals.py +20 -3
  6. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  7. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  8. package/bin/papyrus-infra.mjs +21 -1
  9. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  10. package/components/monitoring-overview-view.tsx +95 -19
  11. package/components/monitoring-panel.tsx +4 -2
  12. package/components/news-desk-client-provider.tsx +8 -2
  13. package/components/news-desk-taxonomy-client.ts +2 -1
  14. package/components/newsroom-ops-overview.tsx +6 -1
  15. package/components/newsroom-references-view.tsx +2 -4
  16. package/components/notification-center.tsx +345 -63
  17. package/components/papyrus-console-shell.tsx +2 -1
  18. package/components/proposals-panel.tsx +21 -24
  19. package/components/proposals-view.tsx +4 -2
  20. package/components/reference-review-card.tsx +80 -63
  21. package/infra/amplify-app-shell.js +18 -2
  22. package/infra/amplify-app-shell.ts +18 -2
  23. package/infra/chat-stack.js +222 -0
  24. package/infra/chat-stack.ts +250 -0
  25. package/infra/chat.test.js +142 -0
  26. package/infra/chat.test.ts +151 -0
  27. package/infra/index.js +1 -0
  28. package/infra/site-config.js +58 -1
  29. package/infra/site-config.ts +78 -1
  30. package/lib/monitoring-client.ts +14 -1
  31. package/lib/monitoring-overview.ts +33 -1
  32. package/lib/newsroom-chat-identity.ts +2 -1
  33. package/lib/newsroom-organizer-nav.ts +1 -0
  34. package/lib/proposals-client.ts +29 -0
  35. package/lib/reader-subject.ts +3 -2
  36. package/lib/review-client.ts +38 -1
  37. package/lib/review-drafts.ts +231 -0
  38. package/lib/review-fixture.ts +36 -2
  39. package/lib/review-requests.ts +64 -24
  40. package/lib/staff-groups.ts +23 -0
  41. package/lib/staging-gate.ts +2 -2
  42. package/package.json +4 -1
  43. package/routes.manifest.json +1 -1
@@ -2,21 +2,20 @@
2
2
 
3
3
  import { generateClient } from "aws-amplify/data";
4
4
  import amplifyOutputs from "papyrus-amplify-outputs";
5
- import { useCallback, useEffect, useMemo, useState } from "react";
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
6
  import { ProposalsView } from "./proposals-view";
7
7
  import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
8
8
  import {
9
- EDIT_PROPOSAL,
10
9
  LIST_PROPOSALS,
11
- TRANSITION_PROPOSAL,
10
+ filterProposals,
12
11
  proposalReceiptMessage,
13
12
  type ProposalFilter,
14
13
  type ProposalMove,
15
- type ProposalReceipt,
16
14
  type ProposalRow,
17
15
  } from "../lib/proposals-admin";
16
+ import { performProposalMove } from "../lib/proposals-client";
18
17
 
19
- type GraphResult = { data?: Record<string, { items?: ProposalRow[]; nextToken?: string | null } | ProposalReceipt | null>; errors?: { message?: string }[] };
18
+ type GraphResult = { data?: Record<string, { items?: ProposalRow[]; nextToken?: string | null } | null>; errors?: { message?: string }[] };
20
19
  const MAXIMUM_PAGES = 20;
21
20
 
22
21
  async function loadProposals(): Promise<ProposalRow[]> {
@@ -34,24 +33,6 @@ async function loadProposals(): Promise<ProposalRow[]> {
34
33
  return rows;
35
34
  }
36
35
 
37
- async function run(query: string, field: string, variables: Record<string, string | undefined>): Promise<ProposalReceipt> {
38
- const client = generateClient({ authMode: "userPool" });
39
- const result = (await client.graphql({ query, variables })) as GraphResult;
40
- if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
41
- return (result.data?.[field] as ProposalReceipt | null) ?? { status: "refused", message: "The action did not answer." };
42
- }
43
-
44
- /** One button: a path of states is one `transitionProposal` call per state, stopping at the first refusal; pin and hold are `editProposal`. */
45
- async function perform(proposalId: string, move: ProposalMove, reason: string): Promise<ProposalReceipt> {
46
- if (move.edit) return run(EDIT_PROPOSAL, "editProposal", { proposalId, action: move.edit });
47
- let receipt: ProposalReceipt = { status: "refused", message: "Nothing to do." };
48
- for (const state of move.path ?? []) {
49
- receipt = await run(TRANSITION_PROPOSAL, "transitionProposal", { proposalId, state, ...(reason ? { reason } : {}) });
50
- if (receipt.status !== "ok") break;
51
- }
52
- return receipt;
53
- }
54
-
55
36
  /** Core to every site: it does not depend on the reader-steering plugin. */
56
37
  export function ProposalsPanel() {
57
38
  const [rows, setRows] = useState<ProposalRow[]>([]);
@@ -59,6 +40,8 @@ export function ProposalsPanel() {
59
40
  const [status, setStatus] = useState("Loading...");
60
41
  const [busy, setBusy] = useState(false);
61
42
  const [reason, setReason] = useState("");
43
+ const [focusId, setFocusId] = useState<string | null>(null);
44
+ const focused = useRef(false);
62
45
  const readers = useMemo(() => pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering") !== null, []);
63
46
 
64
47
  const refresh = useCallback(async (quiet = false) => {
@@ -77,12 +60,25 @@ export function ProposalsPanel() {
77
60
  void refresh();
78
61
  }, [refresh]);
79
62
 
63
+ // Sent here by a link such as `?proposal=<id>`: show the view that holds it and scroll to it.
64
+ useEffect(() => {
65
+ setFocusId(new URLSearchParams(window.location.search).get("proposal"));
66
+ }, []);
67
+ useEffect(() => {
68
+ if (!focusId || focused.current) return;
69
+ const row = rows.find((candidate) => candidate.id === focusId);
70
+ if (!row) return;
71
+ focused.current = true;
72
+ setFilter(filterProposals([row], "open").length ? "open" : "decided");
73
+ window.requestAnimationFrame(() => document.getElementById(`proposal-${focusId}`)?.scrollIntoView({ block: "start" }));
74
+ }, [focusId, rows]);
75
+
80
76
  const onMove = useCallback(
81
77
  async (proposalId: string, move: ProposalMove) => {
82
78
  setBusy(true);
83
79
  setStatus("Working...");
84
80
  try {
85
- const receipt = await perform(proposalId, move, reason.trim());
81
+ const receipt = await performProposalMove(proposalId, move, reason.trim());
86
82
  setStatus(proposalReceiptMessage(receipt));
87
83
  if (receipt.status === "ok") setReason("");
88
84
  } catch (error) {
@@ -105,6 +101,7 @@ export function ProposalsPanel() {
105
101
  onReason={setReason}
106
102
  onMove={(proposalId, move) => void onMove(proposalId, move)}
107
103
  onRefresh={() => void refresh()}
104
+ focusId={focusId}
108
105
  />
109
106
  );
110
107
  }
@@ -32,6 +32,8 @@ export type ProposalsViewProps = {
32
32
  onReason: (reason: string) => void;
33
33
  onMove: (proposalId: string, move: ProposalMove) => void;
34
34
  onRefresh: () => void;
35
+ /** A proposal the editor was sent to (from the Notification Center): its draft opens and it is marked. */
36
+ focusId?: string | null;
35
37
  };
36
38
 
37
39
  export function ProposalsView(props: ProposalsViewProps) {
@@ -62,7 +64,7 @@ export function ProposalsView(props: ProposalsViewProps) {
62
64
  ) : (
63
65
  <ul>
64
66
  {shown.map((row) => (
65
- <li key={row.id} data-proposal-row={row.id}>
67
+ <li key={row.id} id={`proposal-${row.id}`} data-proposal-row={row.id} data-proposal-focused={props.focusId === row.id || undefined}>
66
68
  <article aria-label={row.title}>
67
69
  <p>
68
70
  <strong>{kindLabel(row.kind)}</strong>: {row.title} ({stateLabel(row.state, props.readers)}
@@ -70,7 +72,7 @@ export function ProposalsView(props: ProposalsViewProps) {
70
72
  </p>
71
73
  {row.summary ? <p>{row.summary}</p> : null}
72
74
  {row.body ? (
73
- <details>
75
+ <details open={props.focusId === row.id || undefined}>
74
76
  <summary>Read the draft</summary>
75
77
  <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{row.body}</pre>
76
78
  </details>
@@ -44,6 +44,84 @@ function percent(confidence: number | null): string | null {
44
44
  return confidence === null ? null : `${Math.round(confidence * 100)}%`;
45
45
  }
46
46
 
47
+ /**
48
+ * The Cyclotron's read-only row: its Accept or Reject highlighted with its confidence, and a check or cross once a person has answered.
49
+ * Shared by the review card and by every list that shows a suggestion before a decision.
50
+ */
51
+ export function CyclotronRow({ decided = null, prediction }: { decided?: ReviewChoice | null; prediction: CardPrediction }) {
52
+ const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
53
+ return (
54
+ <div className="space-y-2" data-review-row="cyclotron">
55
+ <div className="flex items-center gap-3">
56
+ <span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
57
+ {prediction.state === "ready" ? (
58
+ <div className="flex flex-1 gap-2">
59
+ {(["accept", "reject"] as const).map((choice) => {
60
+ const predicted = prediction.state === "ready" && prediction.choice === choice;
61
+ const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
62
+ return (
63
+ <span
64
+ className={cn(
65
+ "inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
66
+ predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
67
+ verdict === "match" && "border-green-700 bg-green-100 text-green-950",
68
+ verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
69
+ )}
70
+ data-cyclotron-choice={choice}
71
+ data-predicted={predicted || undefined}
72
+ data-verdict={verdict ?? undefined}
73
+ key={choice}
74
+ >
75
+ {verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
76
+ {verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
77
+ {CHOICE_LABEL[choice]}
78
+ {predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
79
+ {verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
80
+ </span>
81
+ );
82
+ })}
83
+ </div>
84
+ ) : (
85
+ <p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
86
+ {prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
87
+ </p>
88
+ )}
89
+ </div>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ /** The two answers, large enough to tap: Accept and Reject, the chosen one filled and checked (never colour alone). */
95
+ export function ChoiceButtons({ chosen, disabled = false, onChoose }: { chosen: ReviewChoice | null; disabled?: boolean; onChoose: (choice: ReviewChoice) => void }) {
96
+ return (
97
+ <div className="flex flex-1 gap-2">
98
+ {(["accept", "reject"] as const).map((choice) => {
99
+ const on = chosen === choice;
100
+ return (
101
+ <Button
102
+ aria-disabled={disabled ? true : undefined}
103
+ aria-pressed={on}
104
+ className="min-h-14 flex-1 text-base"
105
+ data-review-choice={choice}
106
+ key={choice}
107
+ onClick={() => onChoose(choice)}
108
+ type="button"
109
+ variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
110
+ >
111
+ {on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
112
+ {CHOICE_LABEL[choice]}
113
+ </Button>
114
+ );
115
+ })}
116
+ </div>
117
+ );
118
+ }
119
+
120
+ /** A reference's relevance decision in the card's own vocabulary. */
121
+ export function predictionFromDecision(decision: { label: string; positiveLabel: string | null; confidence: number | null }): CardPrediction {
122
+ return { state: "ready", choice: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", confidence: decision.confidence };
123
+ }
124
+
47
125
  /**
48
126
  * One reusable card for answering a single question: Accept or Reject. The Cyclotron's row is
49
127
  * read-only; the human row is the real control and its answer is final. Saves on tap.
@@ -156,12 +234,6 @@ export function ReviewCard({
156
234
  return () => window.removeEventListener("keydown", onKey);
157
235
  });
158
236
 
159
- const predictedMarker = (choice: ReviewChoice) => {
160
- if (prediction.state !== "ready" || prediction.choice !== choice) return null;
161
- return prediction.choice;
162
- };
163
- const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
164
-
165
237
  return (
166
238
  <section aria-label="Review" className="space-y-4" data-review-card={itemId}>
167
239
  <header className="space-y-1">
@@ -175,67 +247,12 @@ export function ReviewCard({
175
247
  {summary ? <p className="text-sm text-muted-foreground">{summary}</p> : null}
176
248
  </header>
177
249
 
178
- <div className="space-y-2" data-review-row="cyclotron">
179
- <div className="flex items-center gap-3">
180
- <span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
181
- {prediction.state === "ready" ? (
182
- <div className="flex flex-1 gap-2">
183
- {(["accept", "reject"] as const).map((choice) => {
184
- const predicted = predictedMarker(choice) !== null;
185
- const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
186
- return (
187
- <span
188
- className={cn(
189
- "inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
190
- predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
191
- verdict === "match" && "border-green-700 bg-green-100 text-green-950",
192
- verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
193
- )}
194
- data-cyclotron-choice={choice}
195
- data-predicted={predicted || undefined}
196
- data-verdict={verdict ?? undefined}
197
- key={choice}
198
- >
199
- {verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
200
- {verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
201
- {CHOICE_LABEL[choice]}
202
- {predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
203
- {verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
204
- </span>
205
- );
206
- })}
207
- </div>
208
- ) : (
209
- <p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
210
- {prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
211
- </p>
212
- )}
213
- </div>
214
- </div>
250
+ <CyclotronRow decided={decided} prediction={prediction} />
215
251
 
216
252
  <div className="space-y-2" data-review-row="you">
217
253
  <div className="flex items-center gap-3">
218
254
  <span className="w-20 shrink-0 text-sm font-semibold">You</span>
219
- <div className="flex flex-1 gap-2">
220
- {(["accept", "reject"] as const).map((choice) => {
221
- const on = chosen === choice;
222
- return (
223
- <Button
224
- aria-disabled={disabledReason ? true : undefined}
225
- aria-pressed={on}
226
- className="min-h-14 flex-1 text-base"
227
- data-review-choice={choice}
228
- key={choice}
229
- onClick={() => choose(choice)}
230
- type="button"
231
- variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
232
- >
233
- {on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
234
- {CHOICE_LABEL[choice]}
235
- </Button>
236
- );
237
- })}
238
- </div>
255
+ <ChoiceButtons chosen={chosen} disabled={Boolean(disabledReason)} onChoose={choose} />
239
256
  </div>
240
257
  <div className="min-h-5 pl-[5.75rem] text-sm" role="status">
241
258
  {saving ? "Saving…" : saved ? (
@@ -6,10 +6,26 @@ import { addCloudEmail } from "./cloud-email.js";
6
6
  import { parseCloudEmail } from "./cloud-email-config.js";
7
7
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
8
8
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
9
- import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
9
+ import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveChatStackName, chatExportName, CHAT_EXPORTED_RESOURCES, CHAT_ENVIRONMENT_NAMES, CHAT_PRIVATE_ASSISTANT_ID, CHAT_PRIVATE_ASSISTANT_NAME, resolveStoragePreviewPrefix } from "./site-config.js";
10
10
  function environmentVariables(variables) {
11
11
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
12
12
  }
13
+ /**
14
+ * The production branch's private chat environment. Only a site with an enabled `chat` block gets the flag and the
15
+ * resource names (imported from the chat data stack's exports, so that stack must be deployed first); every other
16
+ * site keeps the flag "false" and no chat variables, and its newsroom serves neither the chat route nor the UI.
17
+ */
18
+ function chatEnvironment(config) {
19
+ const chatStackName = resolveChatStackName(config);
20
+ if (!chatStackName)
21
+ return { PAPYRUS_PRIVATE_CHAT_ENABLED: "false" };
22
+ return {
23
+ PAPYRUS_PRIVATE_CHAT_ENABLED: "true",
24
+ ...Object.fromEntries(CHAT_EXPORTED_RESOURCES.map((resource) => [CHAT_ENVIRONMENT_NAMES[resource], Fn.importValue(chatExportName(chatStackName, resource))])),
25
+ PAPYRUS_CHAT_PRIVATE_BOT_ID: CHAT_PRIVATE_ASSISTANT_ID,
26
+ PAPYRUS_CHAT_PRIVATE_BOT_NAME: CHAT_PRIVATE_ASSISTANT_NAME,
27
+ };
28
+ }
13
29
  function readerCustomHeaders(rules) {
14
30
  if (!rules)
15
31
  return undefined;
@@ -199,7 +215,7 @@ export class AmplifyAppShellStack extends Stack {
199
215
  environmentVariables: environmentVariables({
200
216
  ...config.cms.environment,
201
217
  ...emailEnvironment,
202
- PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
218
+ ...(config.chat?.enabled ? chatEnvironment(config) : { PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false" }),
203
219
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
204
220
  ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
205
221
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
@@ -7,12 +7,28 @@ import { addCloudEmail } from "./cloud-email";
7
7
  import { parseCloudEmail } from "./cloud-email-config";
8
8
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
9
9
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
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";
10
+ import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveChatStackName, chatExportName, CHAT_EXPORTED_RESOURCES, CHAT_ENVIRONMENT_NAMES, CHAT_PRIVATE_ASSISTANT_ID, CHAT_PRIVATE_ASSISTANT_NAME, resolveStoragePreviewPrefix } from "./site-config";
11
11
 
12
12
  function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
13
13
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
14
14
  }
15
15
 
16
+ /**
17
+ * The production branch's private chat environment. Only a site with an enabled `chat` block gets the flag and the
18
+ * resource names (imported from the chat data stack's exports, so that stack must be deployed first); every other
19
+ * site keeps the flag "false" and no chat variables, and its newsroom serves neither the chat route nor the UI.
20
+ */
21
+ function chatEnvironment(config: AmplifyAppShellSiteConfig): Record<string, string> {
22
+ const chatStackName = resolveChatStackName(config);
23
+ if (!chatStackName) return { PAPYRUS_PRIVATE_CHAT_ENABLED: "false" };
24
+ return {
25
+ PAPYRUS_PRIVATE_CHAT_ENABLED: "true",
26
+ ...Object.fromEntries(CHAT_EXPORTED_RESOURCES.map((resource) => [CHAT_ENVIRONMENT_NAMES[resource], Fn.importValue(chatExportName(chatStackName, resource))])),
27
+ PAPYRUS_CHAT_PRIVATE_BOT_ID: CHAT_PRIVATE_ASSISTANT_ID,
28
+ PAPYRUS_CHAT_PRIVATE_BOT_NAME: CHAT_PRIVATE_ASSISTANT_NAME,
29
+ };
30
+ }
31
+
16
32
  function readerCustomHeaders(rules: ReaderCustomHeaderRule[] | undefined): string | undefined {
17
33
  if (!rules) return undefined;
18
34
  const lines = ["customHeaders:"];
@@ -221,7 +237,7 @@ export class AmplifyAppShellStack extends Stack {
221
237
  environmentVariables: environmentVariables({
222
238
  ...config.cms.environment,
223
239
  ...emailEnvironment,
224
- PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
240
+ ...(config.chat?.enabled ? chatEnvironment(config) : { PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false" }),
225
241
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
226
242
  ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
227
243
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
@@ -0,0 +1,222 @@
1
+ import { Annotations, Arn, ArnFormat, Aws, CfnOutput, CfnParameter, Duration, Fn, RemovalPolicy, Stack } from "aws-cdk-lib";
2
+ import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
3
+ import * as iam from "aws-cdk-lib/aws-iam";
4
+ import * as lambda from "aws-cdk-lib/aws-lambda";
5
+ import * as eventSources from "aws-cdk-lib/aws-lambda-event-sources";
6
+ import * as logs from "aws-cdk-lib/aws-logs";
7
+ import * as s3 from "aws-cdk-lib/aws-s3";
8
+ import * as sqs from "aws-cdk-lib/aws-sqs";
9
+ import { CHAT_PRIVATE_ASSISTANT_ID, CHAT_PRIVATE_ASSISTANT_NAME, chatExportName, resolveChatLogGroupPrefix, resolveChatStackName, } from "./site-config.js";
10
+ /**
11
+ * The private newsroom chat data plane of one publication: two DynamoDB tables, a private versioned S3 bucket, run
12
+ * and probe queues with DLQs, and the two worker Lambdas with their event source mappings. The CMS compute role
13
+ * receives the scoped gateway policy through the `GatewayRoleArn` parameter (the app shell's `CmsComputeRoleArn`
14
+ * output), and the app shell reads the resource names back through this stack's exports.
15
+ *
16
+ * The resource definitions are the ones the first activated stack `PapyrusChatDataCandidate` was activated from
17
+ * (infra/shared-chat-runtime/cdk/synth.cjs); logical ids and construct order are kept so that stack is adopted by an
18
+ * in-place update (scripts/test-infra-chat.ts compares the two templates).
19
+ */
20
+ export class PapyrusChatStack extends Stack {
21
+ constructor(scope, props) {
22
+ const { config, lambdaAssetDirectory, ...stackProps } = props;
23
+ const chat = config.chat;
24
+ const stackName = resolveChatStackName(config);
25
+ if (!chat || !stackName)
26
+ throw new Error(`site ${config.siteId} does not enable chat`);
27
+ // The construct id is the stack name: the SQS event source mapping logical ids derive from it.
28
+ super(scope, stackName, { ...stackProps, stackName });
29
+ const modelKeyParameter = chat.modelKeyParameter;
30
+ const logGroupPrefix = resolveChatLogGroupPrefix(config);
31
+ const gatewayRoleArn = new CfnParameter(this, "GatewayRoleArn", {
32
+ type: "String",
33
+ description: "The site's CMS compute role ARN (CmsComputeRoleArn output of the app-shell stack); receives the scoped gateway policy.",
34
+ allowedPattern: "^arn:aws:iam::[0-9]{12}:role/.+$",
35
+ });
36
+ const privateBotId = new CfnParameter(this, "PrivateAssistantBotId", {
37
+ type: "String",
38
+ default: CHAT_PRIVATE_ASSISTANT_ID,
39
+ description: "Stable Papyrus-owned assistant ID; stored per derived account tenant on first private-channel open.",
40
+ allowedPattern: "^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$",
41
+ });
42
+ const privateBotName = new CfnParameter(this, "PrivateAssistantBotName", {
43
+ type: "String",
44
+ default: CHAT_PRIVATE_ASSISTANT_NAME,
45
+ description: "Visible name for the private Papyrus assistant.",
46
+ allowedPattern: "^[A-Za-z0-9][A-Za-z0-9 ._-]{1,63}$",
47
+ });
48
+ const messaging = new dynamodb.Table(this, "PrivateMessaging", {
49
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
50
+ sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
51
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
52
+ timeToLiveAttribute: "expires_at",
53
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
54
+ deletionProtection: true,
55
+ removalPolicy: RemovalPolicy.RETAIN,
56
+ });
57
+ const conversations = new dynamodb.Table(this, "PrivateConversationIndex", {
58
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
59
+ sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
60
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
61
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
62
+ deletionProtection: true,
63
+ removalPolicy: RemovalPolicy.RETAIN,
64
+ });
65
+ for (const name of ["l1", "l2", "l3"]) {
66
+ conversations.addLocalSecondaryIndex({
67
+ indexName: `${name}-index`,
68
+ sortKey: { name, type: dynamodb.AttributeType.STRING },
69
+ projectionType: dynamodb.ProjectionType.ALL,
70
+ });
71
+ }
72
+ const objects = new s3.Bucket(this, "PrivateSessionObjects", {
73
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
74
+ encryption: s3.BucketEncryption.S3_MANAGED,
75
+ enforceSSL: true,
76
+ versioned: true,
77
+ removalPolicy: RemovalPolicy.RETAIN,
78
+ objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
79
+ });
80
+ const queue = (id, extra = {}) => new sqs.Queue(this, id, { encryption: sqs.QueueEncryption.SQS_MANAGED, enforceSSL: true, ...extra });
81
+ const runDlq = queue("TurnRunDlq", { retentionPeriod: Duration.days(14) });
82
+ const probeDlq = queue("TurnProbeDlq", { retentionPeriod: Duration.days(14) });
83
+ const runs = queue("TurnRuns", {
84
+ visibilityTimeout: Duration.seconds(1800),
85
+ retentionPeriod: Duration.days(4),
86
+ deadLetterQueue: { queue: runDlq, maxReceiveCount: 5 },
87
+ });
88
+ const probes = queue("TurnProbes", {
89
+ visibilityTimeout: Duration.minutes(2),
90
+ retentionPeriod: Duration.days(4),
91
+ deadLetterQueue: { queue: probeDlq, maxReceiveCount: 5 },
92
+ });
93
+ const gateway = iam.Role.fromRoleArn(this, "PapyrusChatGatewayRole", gatewayRoleArn.valueAsString, { mutable: true });
94
+ gateway.addToPrincipalPolicy(new iam.PolicyStatement({
95
+ actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:TransactWriteItems", "dynamodb:Query", "dynamodb:BatchGetItem"],
96
+ resources: [messaging.tableArn],
97
+ }));
98
+ gateway.addToPrincipalPolicy(new iam.PolicyStatement({
99
+ actions: ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:BatchGetItem"],
100
+ resources: [conversations.tableArn, `${conversations.tableArn}/index/*`],
101
+ }));
102
+ gateway.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ["s3:GetObject"], resources: [objects.arnForObjects("conversations/*")] }));
103
+ gateway.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ["sqs:SendMessage"], resources: [runs.queueArn, probes.queueArn] }));
104
+ // Appended last, as in the activated stack's policy (the transaction-check correction was added to it afterwards).
105
+ gateway.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ["dynamodb:ConditionCheckItem"], resources: [messaging.tableArn] }));
106
+ const hostedEnvironment = {
107
+ PAPYRUS_CHAT_MESSAGING_TABLE: messaging.tableName,
108
+ PAPYRUS_CHAT_CONVERSATIONS_TABLE: conversations.tableName,
109
+ PAPYRUS_CHAT_SESSION_BUCKET: objects.bucketName,
110
+ PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL: runs.queueUrl,
111
+ PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL: probes.queueUrl,
112
+ PAPYRUS_CHAT_PRIVATE_BOT_ID: privateBotId.valueAsString,
113
+ PAPYRUS_CHAT_PRIVATE_BOT_NAME: privateBotName.valueAsString,
114
+ };
115
+ const runLogGroup = new logs.LogGroup(this, "RunWorkerLogs", {
116
+ logGroupName: `${logGroupPrefix}-run-worker`,
117
+ retention: logs.RetentionDays.ONE_MONTH,
118
+ removalPolicy: RemovalPolicy.RETAIN,
119
+ });
120
+ const probeLogGroup = new logs.LogGroup(this, "ProbeWorkerLogs", {
121
+ logGroupName: `${logGroupPrefix}-probe-worker`,
122
+ retention: logs.RetentionDays.ONE_MONTH,
123
+ removalPolicy: RemovalPolicy.RETAIN,
124
+ });
125
+ const runWorker = new iam.Role(this, "RunWorkerRole", {
126
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
127
+ description: "Private Papyrus computerless chat run worker.",
128
+ });
129
+ runWorker.addToPolicy(new iam.PolicyStatement({
130
+ actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:BatchGetItem", "dynamodb:TransactWriteItems"],
131
+ resources: [messaging.tableArn, conversations.tableArn, `${conversations.tableArn}/index/*`],
132
+ }));
133
+ runWorker.addToPolicy(new iam.PolicyStatement({
134
+ actions: ["ssm:GetParameter"],
135
+ // Pseudo parameters, not the literal account: the template stays environment-agnostic like the activated stack.
136
+ resources: [Arn.format({ partition: Aws.PARTITION, region: Aws.REGION, account: Aws.ACCOUNT_ID, service: "ssm", resource: "parameter", resourceName: modelKeyParameter.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME })],
137
+ }));
138
+ runWorker.addToPolicy(new iam.PolicyStatement({ actions: ["logs:CreateLogStream", "logs:PutLogEvents"], resources: [`${runLogGroup.logGroupArn}:*`] }));
139
+ runWorker.addToPolicy(new iam.PolicyStatement({
140
+ actions: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
141
+ resources: [objects.arnForObjects("conversations/*")],
142
+ }));
143
+ runWorker.addToPolicy(new iam.PolicyStatement({ actions: ["sqs:ChangeMessageVisibility", "sqs:SendMessage"], resources: [runs.queueArn, probes.queueArn] }));
144
+ const probeWorker = new iam.Role(this, "ProbeWorkerRole", {
145
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
146
+ description: "Private Papyrus chat deadline/recovery worker.",
147
+ });
148
+ probeWorker.addToPolicy(new iam.PolicyStatement({
149
+ actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:TransactWriteItems", "dynamodb:BatchGetItem"],
150
+ resources: [messaging.tableArn],
151
+ }));
152
+ probeWorker.addToPolicy(new iam.PolicyStatement({
153
+ actions: ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:BatchGetItem"],
154
+ resources: [conversations.tableArn, `${conversations.tableArn}/index/*`],
155
+ }));
156
+ probeWorker.addToPolicy(new iam.PolicyStatement({ actions: ["s3:GetObject"], resources: [objects.arnForObjects("conversations/*")] }));
157
+ probeWorker.addToPolicy(new iam.PolicyStatement({ actions: ["sqs:SendMessage"], resources: [runs.queueArn, probes.queueArn] }));
158
+ probeWorker.addToPolicy(new iam.PolicyStatement({ actions: ["logs:CreateLogStream", "logs:PutLogEvents"], resources: [`${probeLogGroup.logGroupArn}:*`] }));
159
+ let code;
160
+ if (chat.workerCodeS3Key) {
161
+ // Already uploaded and verified bytes: the template names the object, nothing is bundled or uploaded.
162
+ const assets = s3.Bucket.fromBucketName(this, "WorkerCodeAssets", Fn.sub("cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}"));
163
+ code = lambda.Code.fromBucket(assets, chat.workerCodeS3Key);
164
+ Annotations.of(assets).acknowledgeWarning("@aws-cdk/aws-lambda:codeFromBucketObjectVersionNotSpecified", "the key is a content hash; it is never overwritten");
165
+ }
166
+ else {
167
+ if (!lambdaAssetDirectory)
168
+ throw new Error(`site ${config.siteId}: bundle the chat workers first (scripts/bundle-chat-lambdas.mjs) and pass -c chatLambdas=<dir>, or pin chat.workerCodeS3Key`);
169
+ code = lambda.Code.fromAsset(lambdaAssetDirectory);
170
+ }
171
+ const runWorkerFunction = new lambda.Function(this, "RunWorker", {
172
+ runtime: lambda.Runtime.NODEJS_22_X,
173
+ handler: "run-worker.handler",
174
+ code,
175
+ role: runWorker,
176
+ memorySize: 1024,
177
+ timeout: Duration.seconds(900),
178
+ logGroup: runLogGroup,
179
+ environment: { ...hostedEnvironment, OPENAI_API_KEY_PARAMETER: modelKeyParameter },
180
+ description: "Runs Papyrus private chat turns with the computerless Chatticus execution profile.",
181
+ });
182
+ const probeWorkerFunction = new lambda.Function(this, "ProbeWorker", {
183
+ runtime: lambda.Runtime.NODEJS_22_X,
184
+ handler: "probe-worker.handler",
185
+ code,
186
+ role: probeWorker,
187
+ memorySize: 512,
188
+ timeout: Duration.seconds(60),
189
+ logGroup: probeLogGroup,
190
+ environment: hostedEnvironment,
191
+ description: "Recovers and finalizes Papyrus private chat turns.",
192
+ });
193
+ // The model key parameter exists for every configured site, so the workers run from the first deploy.
194
+ runWorkerFunction.addEventSource(new eventSources.SqsEventSource(runs, { enabled: true, batchSize: 1 }));
195
+ probeWorkerFunction.addEventSource(new eventSources.SqsEventSource(probes, { enabled: true, batchSize: 1 }));
196
+ // Last, as in the activated stack (the storage-owner fence was added after the event source grants).
197
+ runWorker.addToPolicy(new iam.PolicyStatement({ actions: ["dynamodb:ConditionCheckItem"], resources: [conversations.tableArn] }));
198
+ const exported = (id, value, exportKey) => new CfnOutput(this, id, { value, ...(exportKey ? { exportName: chatExportName(stackName, exportKey) } : {}) });
199
+ exported("MessagingTableName", messaging.tableName, "MessagingTableName");
200
+ exported("ConversationIndexTableName", conversations.tableName, "ConversationIndexTableName");
201
+ exported("SessionBucketName", objects.bucketName, "SessionBucketName");
202
+ exported("TurnRunsQueueUrl", runs.queueUrl, "TurnRunsQueueUrl");
203
+ exported("TurnProbesQueueUrl", probes.queueUrl, "TurnProbesQueueUrl");
204
+ exported("FeatureDefault", "enabled per site by the chat block of its site.json (PAPYRUS_PRIVATE_CHAT_ENABLED on the CMS production branch)");
205
+ exported("SyntheticAdapterTestOnly", "false");
206
+ exported("GatewayMessagingTableEnvironmentName", "PAPYRUS_CHAT_MESSAGING_TABLE");
207
+ exported("GatewayMessagingTableName", messaging.tableName);
208
+ exported("GatewayConversationTableEnvironmentName", "PAPYRUS_CHAT_CONVERSATIONS_TABLE");
209
+ exported("GatewayConversationTableName", conversations.tableName);
210
+ exported("GatewaySessionBucketEnvironmentName", "PAPYRUS_CHAT_SESSION_BUCKET");
211
+ exported("GatewaySessionBucketName", objects.bucketName);
212
+ exported("GatewayRunQueueEnvironmentName", "PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL");
213
+ exported("GatewayProbeQueueEnvironmentName", "PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL");
214
+ exported("GatewayPrivateBotIdEnvironmentName", "PAPYRUS_CHAT_PRIVATE_BOT_ID");
215
+ exported("PrivateAssistantBotIdValue", privateBotId.valueAsString);
216
+ exported("GatewayPrivateBotNameEnvironmentName", "PAPYRUS_CHAT_PRIVATE_BOT_NAME");
217
+ exported("PrivateAssistantBotNameValue", privateBotName.valueAsString);
218
+ exported("WorkerModelKeyParameterName", modelKeyParameter);
219
+ exported("RunWorkerFunctionName", runWorkerFunction.functionName);
220
+ exported("ProbeWorkerFunctionName", probeWorkerFunction.functionName);
221
+ }
222
+ }