@anthusai/papyrus 5.19.1 → 5.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/amplify/data/resource.js +1 -0
  2. package/amplify/data/resource.ts +1 -0
  3. package/amplify/data/schema.js +1 -0
  4. package/amplify/data/schema.ts +1 -0
  5. package/amplify/functions/assignment-action/data-access.js +2 -0
  6. package/amplify/functions/assignment-action/data-access.ts +2 -0
  7. package/amplify/functions/assignment-action/handler.ts +1 -1
  8. package/amplify/functions/content-actions/handler.py +20 -2
  9. package/amplify/functions/shared/editorial-context.ts +63 -4
  10. package/amplify/plugins/newsletter/config.js +195 -0
  11. package/amplify/plugins/newsletter/config.ts +240 -0
  12. package/amplify/plugins/newsletter/function/handler.py +6 -0
  13. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  14. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  15. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  16. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  17. package/amplify/plugins/newsletter/papyrus.plugin.js +260 -0
  18. package/amplify/plugins/newsletter/papyrus.plugin.ts +308 -0
  19. package/amplify/plugins/newsletter/schema.js +56 -0
  20. package/amplify/plugins/newsletter/schema.ts +61 -0
  21. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  22. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  23. package/amplify/plugins/newsletter/sending.js +140 -0
  24. package/amplify/plugins/newsletter/sending.ts +186 -0
  25. package/amplify/plugins/reader-steering/config.js +162 -0
  26. package/amplify/plugins/reader-steering/config.ts +248 -0
  27. package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
  28. package/amplify/plugins/reader-steering/function/handler.py +1363 -0
  29. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  30. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  31. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  32. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  33. package/amplify/plugins/reader-steering/papyrus.plugin.js +175 -0
  34. package/amplify/plugins/reader-steering/papyrus.plugin.ts +201 -0
  35. package/amplify/plugins/reader-steering/schema.js +140 -0
  36. package/amplify/plugins/reader-steering/schema.ts +142 -0
  37. package/amplify/plugins/registry.js +4 -0
  38. package/amplify/plugins/registry.ts +4 -0
  39. package/amplify/plugins/types.ts +5 -0
  40. package/amplify/plugins/wire.js +1 -0
  41. package/amplify/plugins/wire.ts +1 -0
  42. package/app/api/newsletter/confirm/route.ts +13 -0
  43. package/app/api/newsletter/preferences/route.ts +13 -0
  44. package/app/api/newsletter/subscribe/route.ts +9 -0
  45. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  46. package/app/api/reader/comment/delete/route.ts +9 -0
  47. package/app/api/reader/comment/edit/route.ts +9 -0
  48. package/app/api/reader/comment/route.ts +9 -0
  49. package/app/api/reader/handle/route.ts +9 -0
  50. package/app/api/reader/me/route.ts +9 -0
  51. package/app/api/reader/rating/route.ts +9 -0
  52. package/app/api/reader/report/route.ts +9 -0
  53. package/app/api/reader/retired/route.ts +9 -0
  54. package/app/api/reader/submit/route.ts +9 -0
  55. package/app/api/reader/vote/route.ts +9 -0
  56. package/components/moderation-panel.tsx +248 -0
  57. package/components/newsletter-panel.tsx +474 -0
  58. package/components/newsletter-sections.tsx +163 -0
  59. package/components/newsroom-organizer.css +14 -0
  60. package/components/reference-cyclotron-priming.tsx +54 -15
  61. package/components/topic-steering-workspace.tsx +42 -5
  62. package/infra/amplify-app-shell.js +15 -1
  63. package/infra/amplify-app-shell.ts +17 -1
  64. package/infra/site-config.js +12 -2
  65. package/infra/site-config.ts +19 -2
  66. package/lib/editorial-checks.ts +6 -2
  67. package/lib/newsletter-api.ts +391 -0
  68. package/lib/newsletter-client.ts +46 -0
  69. package/lib/newsletter-fixture.ts +184 -0
  70. package/lib/newsletter-model.ts +202 -0
  71. package/lib/newsletter-pages.ts +264 -0
  72. package/lib/prospectus-session.ts +1 -0
  73. package/lib/prospectus-sign-in-routes.ts +2 -2
  74. package/lib/prospectus-site-identity.ts +24 -1
  75. package/lib/reader-api.ts +145 -0
  76. package/lib/reader-moderation.ts +220 -0
  77. package/lib/reader-retirement-route.ts +89 -0
  78. package/lib/reader-subject.md +29 -0
  79. package/lib/reader-subject.ts +53 -0
  80. package/package.json +27 -1
  81. package/routes.manifest.json +158 -1
@@ -1,6 +1,6 @@
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 };
@@ -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 {
@@ -38,13 +51,13 @@ export function ReferenceCyclotronPriming({
38
51
  return (
39
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">
60
+ <label className="flex flex-col gap-1">
48
61
  <span className="text-xs text-muted-foreground">Web page address</span>
49
62
  <input
50
63
  aria-label="Reference URL"
@@ -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">
@@ -15,6 +15,8 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSta
15
15
  import amplifyOutputs from "papyrus-amplify-outputs";
16
16
  import { EditorialChecksAdminPanel } from "./editorial-checks-admin-panel";
17
17
  import { MonitoringPanel } from "./monitoring-panel";
18
+ import { ModerationPanel } from "./moderation-panel";
19
+ import { NewsletterPanel } from "./newsletter-panel";
18
20
  import { SignupEntriesPanel } from "./signup-entries-panel";
19
21
  import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
20
22
  import { generateClient } from "aws-amplify/data";
@@ -249,7 +251,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
249
251
  type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
250
252
  type AssignmentDeskViewMode = "queue" | "budget";
251
253
  type UserRoleAction = "grant" | "revoke";
252
- type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "editorial-checks" | "monitoring";
254
+ type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "newsletter" | "moderation" | "editorial-checks" | "monitoring";
253
255
  export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
254
256
  type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
255
257
  type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
@@ -881,7 +883,7 @@ function NewsDeskDashboard({
881
883
  const [mergeSelection, setMergeSelection] = useState<MergeSelection | null>(null);
882
884
  const [isPending, startTransition] = useTransition();
883
885
  const controlsDisabled = isPending || !canEdit;
884
- const initialAdministrationPanel = normalizeAdministrationPanel(initialSelection.panel);
886
+ const initialAdministrationPanel = normalizeAdministrationPanel(initialSelection.panel, Boolean(dashboard.isDemo));
885
887
  const [administrationPanel, setAdministrationPanel] = useState<AdministrationPanel>(initialAdministrationPanel);
886
888
  const [doctrineDrafts, setDoctrineDrafts] = useState<DoctrineEditorState>(() => buildDoctrineEditorState(dashboard.doctrineRecords));
887
889
  const [loadedSections, setLoadedSections] = useState<Record<string, boolean>>({
@@ -1122,8 +1124,8 @@ function NewsDeskDashboard({
1122
1124
  }, [dashboard.procedureDefinitions, dashboard.procedureRuns, dashboard.procedureVersions]);
1123
1125
 
1124
1126
  useEffect(() => {
1125
- setAdministrationPanel(normalizeAdministrationPanel(initialSelection.panel));
1126
- }, [initialSelection.panel]);
1127
+ setAdministrationPanel(normalizeAdministrationPanel(initialSelection.panel, Boolean(dashboard.isDemo)));
1128
+ }, [initialSelection.panel, dashboard.isDemo]);
1127
1129
 
1128
1130
  useEffect(() => {
1129
1131
  if (activeTab !== "administration") return;
@@ -4351,9 +4353,33 @@ function AdministrationDeskView({
4351
4353
  <span>Addresses from the site&apos;s signup forms</span>
4352
4354
  </Link>
4353
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}
4354
4378
  </nav>}
4355
4379
  <div className="news-desk-settings-panel">
4356
4380
  {administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
4381
+ {administrationPanel === "newsletter" ? <NewsletterPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
4382
+ {administrationPanel === "moderation" ? <ModerationPanel /> : null}
4357
4383
  {administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
4358
4384
  {administrationPanel === "monitoring" ? <MonitoringPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
4359
4385
  {administrationPanel === "delegations" ? <AdministrationDelegationsPanel disabled={disabled} /> : null}
@@ -12819,9 +12845,20 @@ function signupPluginEnabled(): boolean {
12819
12845
  return pluginSettings(amplifyOutputs as PluginOutputs, "signup") !== null;
12820
12846
  }
12821
12847
 
12822
- function normalizeAdministrationPanel(value: string | null | undefined): AdministrationPanel {
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;
12855
+ }
12856
+
12857
+ function normalizeAdministrationPanel(value: string | null | undefined, demo = false): AdministrationPanel {
12823
12858
  if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
12824
12859
  if (value === "signups" && signupPluginEnabled()) return value;
12860
+ if (value === "newsletter" && newsletterPluginEnabled(demo)) return value;
12861
+ if (value === "moderation" && readerPluginEnabled()) return value;
12825
12862
  return "users";
12826
12863
  }
12827
12864
 
@@ -4,7 +4,7 @@ import * as amplify from "aws-cdk-lib/aws-amplify";
4
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, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, 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
  }
@@ -123,6 +123,20 @@ export class AmplifyAppShellStack extends Stack {
123
123
  resources: [prospectusSessionSealing.parameterArn],
124
124
  }));
125
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],
138
+ }));
139
+ }
126
140
  if (prospectusAccountsApiStageArn) {
127
141
  computeRole.addToPolicy(new iam.PolicyStatement({
128
142
  sid: "InvokeProspectusAccountsAsRegisteredSite",
@@ -5,7 +5,7 @@ import * as amplify from "aws-cdk-lib/aws-amplify";
5
5
  import { SecureStringParameter } from "./secure-string-parameter";
6
6
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
7
7
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
8
- import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
8
+ import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
9
9
 
10
10
  function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
11
11
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
@@ -136,6 +136,22 @@ export class AmplifyAppShellStack extends Stack {
136
136
  }),
137
137
  );
138
138
  }
139
+ if (prospectusAccountsApiStageArn && config.cms.readerRetirementPoll) {
140
+ // Prospectus admits one caller per site, this compute role, so the scheduled snapshot run reaches the retired-subject
141
+ // route through the CMS route /api/reader/retired, which it authenticates with this secret.
142
+ const readerPollSecret = new SecureStringParameter(this, "ReaderPollSecretParameter", {
143
+ parameterName: resolveReaderPollSecretParameterName(config),
144
+ description: `Authorizes the scheduled reader snapshot run to call the retired-subject route of ${cmsAppName}`,
145
+ });
146
+ prospectusEnvironment.PAPYRUS_READER_POLL_SECRET_PARAMETER = readerPollSecret.parameterName;
147
+ computeRole.addToPolicy(
148
+ new iam.PolicyStatement({
149
+ sid: "ReadReaderPollSecret",
150
+ actions: ["ssm:GetParameter"],
151
+ resources: [readerPollSecret.parameterArn],
152
+ }),
153
+ );
154
+ }
139
155
  if (prospectusAccountsApiStageArn) {
140
156
  computeRole.addToPolicy(
141
157
  new iam.PolicyStatement({
@@ -35,7 +35,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
35
35
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
36
36
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
37
37
  const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
38
- const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_SITE_ORIGIN"];
38
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
39
39
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
40
40
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
41
41
  "GET/v1/identities/me",
@@ -63,6 +63,9 @@ function originOfRedirectUrl(value) {
63
63
  export function resolveProspectusSessionSealingParameterName(config) {
64
64
  return `/papyrus/${config.siteId}/prospectus-session-sealing`;
65
65
  }
66
+ export function resolveReaderPollSecretParameterName(config) {
67
+ return `/papyrus/${config.siteId}/reader-poll-secret`;
68
+ }
66
69
  export function resolveRevalidateSecretParameterName(config) {
67
70
  return `/papyrus/${config.siteId}/revalidate-secret`;
68
71
  }
@@ -222,7 +225,7 @@ function parseStagingPreview(value) {
222
225
  }
223
226
  function parseCms(value) {
224
227
  const cmsRecord = requireObject(value, "cms");
225
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
228
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "readerRetirementPoll", "deployStackInBuild"], "cms");
226
229
  const buildComputeType = cmsRecord.buildComputeType;
227
230
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
228
231
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -233,6 +236,12 @@ function parseCms(value) {
233
236
  if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
234
237
  fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
235
238
  }
239
+ if (cmsRecord.readerRetirementPoll !== undefined && typeof cmsRecord.readerRetirementPoll !== "boolean") {
240
+ fail("cms.readerRetirementPoll", `must be a boolean, got ${JSON.stringify(cmsRecord.readerRetirementPoll)}`);
241
+ }
242
+ if (cmsRecord.readerRetirementPoll === true && cmsRecord.prospectusAccountsApiStageArn === undefined) {
243
+ fail("cms.readerRetirementPoll", "requires cms.prospectusAccountsApiStageArn: the poll calls Prospectus as the site");
244
+ }
236
245
  if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
237
246
  fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
238
247
  }
@@ -250,6 +259,7 @@ function parseCms(value) {
250
259
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
251
260
  redirects: parseRedirects(cmsRecord.redirects),
252
261
  deployStackInBuild: cmsRecord.deployStackInBuild,
262
+ readerRetirementPoll: cmsRecord.readerRetirementPoll,
253
263
  stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
254
264
  prospectusAccountsApiStageArn: optionalString(cmsRecord.prospectusAccountsApiStageArn, "cms.prospectusAccountsApiStageArn", PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN, "arn:aws:execute-api:<region>:<account>:<api id>/api"),
255
265
  };
@@ -44,6 +44,12 @@ export type CmsConfig = {
44
44
  stagingDomainPrefix?: string;
45
45
  redirects?: CmsRedirect[];
46
46
  prospectusAccountsApiStageArn?: string;
47
+ /**
48
+ * The site is a public-reader site that polls Prospectus for retired reader subjects. The template then generates a
49
+ * shared secret that the scheduled snapshot run presents to the CMS route `/api/reader/retired` (requires
50
+ * `prospectusAccountsApiStageArn`; docs/reader-steering.md).
51
+ */
52
+ readerRetirementPoll?: boolean;
47
53
  deployStackInBuild?: boolean;
48
54
  };
49
55
 
@@ -146,7 +152,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
146
152
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
147
153
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
148
154
  const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
149
- const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_SITE_ORIGIN"];
155
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
150
156
 
151
157
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
152
158
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
@@ -177,6 +183,10 @@ export function resolveProspectusSessionSealingParameterName(config: Pick<Amplif
177
183
  return `/papyrus/${config.siteId}/prospectus-session-sealing`;
178
184
  }
179
185
 
186
+ export function resolveReaderPollSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
187
+ return `/papyrus/${config.siteId}/reader-poll-secret`;
188
+ }
189
+
180
190
  export function resolveRevalidateSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
181
191
  return `/papyrus/${config.siteId}/revalidate-secret`;
182
192
  }
@@ -336,7 +346,7 @@ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
336
346
 
337
347
  function parseCms(value: unknown): CmsConfig {
338
348
  const cmsRecord = requireObject(value, "cms");
339
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
349
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "readerRetirementPoll", "deployStackInBuild"], "cms");
340
350
  const buildComputeType = cmsRecord.buildComputeType;
341
351
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
342
352
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -347,6 +357,12 @@ function parseCms(value: unknown): CmsConfig {
347
357
  if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
348
358
  fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
349
359
  }
360
+ if (cmsRecord.readerRetirementPoll !== undefined && typeof cmsRecord.readerRetirementPoll !== "boolean") {
361
+ fail("cms.readerRetirementPoll", `must be a boolean, got ${JSON.stringify(cmsRecord.readerRetirementPoll)}`);
362
+ }
363
+ if (cmsRecord.readerRetirementPoll === true && cmsRecord.prospectusAccountsApiStageArn === undefined) {
364
+ fail("cms.readerRetirementPoll", "requires cms.prospectusAccountsApiStageArn: the poll calls Prospectus as the site");
365
+ }
350
366
  if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
351
367
  fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
352
368
  }
@@ -369,6 +385,7 @@ function parseCms(value: unknown): CmsConfig {
369
385
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
370
386
  redirects: parseRedirects(cmsRecord.redirects),
371
387
  deployStackInBuild: cmsRecord.deployStackInBuild as boolean | undefined,
388
+ readerRetirementPoll: cmsRecord.readerRetirementPoll as boolean | undefined,
372
389
  stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
373
390
  prospectusAccountsApiStageArn: optionalString(
374
391
  cmsRecord.prospectusAccountsApiStageArn,
@@ -38,7 +38,8 @@ export type EditorialSegment = {
38
38
  text?: string;
39
39
  };
40
40
 
41
- export type LaneStatus = { status: "ran" | "disabled" | "unavailable" | "failed" | "partial"; reason?: string; detectors?: number };
41
+ /** "skipped": Papyrus did not ask for judgments because the publication has no mission or policy for them to apply. */
42
+ export type LaneStatus = { status: "ran" | "disabled" | "unavailable" | "failed" | "partial" | "skipped"; reason?: string; detectors?: number };
42
43
 
43
44
  export type EditorialCheckResult = {
44
45
  schemaVersion: 1;
@@ -49,7 +50,8 @@ export type EditorialCheckResult = {
49
50
  protected: { kind: string; start: number; end: number }[];
50
51
  findings: EditorialFinding[];
51
52
  coverage: unknown[];
52
- lanes: { rules: LaneStatus; classifier: LaneStatus };
53
+ /** lanes.section is "skipped" when the story's section has no NewsroomSection record to take a mission and policy from. */
54
+ lanes: { rules: LaneStatus; classifier: LaneStatus; section?: LaneStatus };
53
55
  usage?: Record<string, number>;
54
56
  };
55
57
 
@@ -164,8 +166,10 @@ export function describeProbability(finding: EditorialFinding): string | null {
164
166
 
165
167
  export function laneNotice(result: EditorialCheckResult): string | null {
166
168
  const classifier = result.lanes.classifier;
169
+ if (classifier.status === "skipped") return `Mission-based checks skipped: ${classifier.reason ?? "this publication has no mission yet"}.`;
167
170
  if (classifier.status === "partial") return `Some sentences were not judged: ${classifier.reason ?? "the check ran out of time"}.`;
168
171
  if (classifier.status === "failed") return `The judgment checks failed: ${classifier.reason ?? "unknown error"}. Rule checks still ran.`;
169
172
  if (classifier.status === "unavailable" && (classifier.detectors ?? 0) === 0 && classifier.reason) return `Judgment checks are not configured: ${classifier.reason}.`;
173
+ if (result.lanes.section?.status === "skipped") return `Section-based checks skipped: ${result.lanes.section.reason ?? "no section configuration"}.`;
170
174
  return null;
171
175
  }