@anthusai/papyrus 5.33.2 → 5.35.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 (34) hide show
  1. package/amplify/auth/resource.js +16 -1
  2. package/amplify/auth/resource.ts +19 -1
  3. package/amplify/site-backend-config.ts +1 -1
  4. package/amplify/site-backend-names.js +4 -2
  5. package/amplify/site-backend-names.ts +4 -2
  6. package/amplify/site-backend.js +5 -2
  7. package/amplify/site-backend.ts +6 -2
  8. package/components/news-desk-client-provider.tsx +18 -18
  9. package/components/news-desk-taxonomy-client.ts +4 -7
  10. package/components/newsroom-access-gate.tsx +15 -1
  11. package/components/newsroom-article-editor.tsx +4 -2
  12. package/components/newsroom-articles-view.tsx +6 -6
  13. package/components/newsroom-board-view.tsx +8 -3
  14. package/components/newsroom-ops-overview.tsx +12 -100
  15. package/components/newsroom-ops-shell.tsx +2 -2
  16. package/components/newsroom-organizer.css +2 -0
  17. package/components/newsroom-overview-skeleton.tsx +25 -0
  18. package/components/notification-center.tsx +13 -2
  19. package/components/reader-auth-state.ts +1 -1
  20. package/components/topic-steering-workspace.tsx +1 -2
  21. package/infra/amplify-app-shell.js +4 -0
  22. package/infra/amplify-app-shell.ts +4 -0
  23. package/infra/cloud-email-config.js +32 -0
  24. package/infra/cloud-email-config.ts +39 -0
  25. package/infra/cloud-email.js +57 -0
  26. package/infra/cloud-email.test.js +69 -0
  27. package/infra/cloud-email.test.ts +66 -0
  28. package/infra/cloud-email.ts +60 -0
  29. package/infra/site-config.js +4 -2
  30. package/infra/site-config.ts +7 -2
  31. package/lib/news-desk-session.ts +12 -21
  32. package/lib/newsroom-organizer-nav.ts +2 -1
  33. package/package.json +2 -1
  34. package/routes.manifest.json +1 -1
@@ -131,9 +131,20 @@ export function NotificationCenterView({ loadState, requests, readIds, held, aut
131
131
  const entry = (request: ReviewRequest) => (
132
132
  <Entry busy={busyId === request.id} demo={demo} key={request.id} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
133
133
  );
134
- if (loadState === "loading") return <div role="status" data-notification-state="loading"><strong>Loading the Notification Center</strong></div>;
134
+ if (loadState === "loading") return (
135
+ <section aria-label="Loading Notification Center" className="space-y-4" data-notification-state="loading" role="status">
136
+ <div className="h-6 w-48 rounded bg-muted" />
137
+ <div className="h-4 w-3/4 max-w-xl rounded bg-muted" />
138
+ <div className="h-4 w-2/3 max-w-lg rounded bg-muted" />
139
+ <div className="grid gap-3 sm:grid-cols-2">
140
+ {Array.from({ length: 4 }, (_, index) => <div className="h-28 rounded-xl border bg-card p-4" key={index}>
141
+ <div className="h-4 w-2/3 rounded bg-muted" /><div className="mt-4 h-3 w-1/2 rounded bg-muted" />
142
+ </div>)}
143
+ </div>
144
+ </section>
145
+ );
135
146
  if (loadState === "forbidden") return <div role="alert" data-notification-state="forbidden"><strong>The Notification Center is for editors and admins</strong></div>;
136
- if (loadState === "unavailable") return <div role="alert" data-notification-state="unavailable"><strong>Review requests could not be read right now</strong><p>Try again in a minute. Nothing waiting on a person has changed.</p></div>;
147
+ if (loadState === "unavailable") return <div role="alert" data-notification-state="unavailable"><strong>Review requests could not be read right now</strong><p>Try again in a minute. The current request state is unknown.</p></div>;
137
148
  return (
138
149
  <div className="space-y-6" data-notification-center>
139
150
  <header className="space-y-1">
@@ -61,7 +61,7 @@ export async function loadReaderSessionSnapshot(options?: { forceRefresh?: boole
61
61
 
62
62
  export function isUnauthenticatedError(error: unknown): boolean {
63
63
  const message = error instanceof Error ? error.message : String(error);
64
- return /unauth|not authenticated|no current user|not signed in/i.test(message);
64
+ return /unauth|not.?authorized|not authenticated|no current user|not signed in|session expired|token expired|invalid.*token|\b401\b/i.test(message);
65
65
  }
66
66
 
67
67
  function readIdentityLabel(claims: {
@@ -674,7 +674,7 @@ export function NewsDeskWorkspace({
674
674
  );
675
675
  }
676
676
 
677
- if (session && sessionDashboard) {
677
+ if (session && sessionDashboard && session.shell.phase !== "loadingDesk") {
678
678
  return (
679
679
  <NewsDeskDashboard
680
680
  analysisProfiles={analysisProfiles}
@@ -3081,7 +3081,6 @@ function NewsDeskDashboard({
3081
3081
  decisions={relevanceDecisions}
3082
3082
  demo={Boolean(dashboard.isDemo)}
3083
3083
  humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
3084
- messages={messages}
3085
3084
  newsroomSections={newsroomSections}
3086
3085
  references={references}
3087
3086
  />
@@ -2,6 +2,8 @@ import { ArnFormat, CfnOutput, Duration, Fn, Stack } from "aws-cdk-lib";
2
2
  import * as iam from "aws-cdk-lib/aws-iam";
3
3
  import * as amplify from "aws-cdk-lib/aws-amplify";
4
4
  import { SecureStringParameter } from "./secure-string-parameter.js";
5
+ import { addCloudEmail } from "./cloud-email.js";
6
+ import { parseCloudEmail } from "./cloud-email-config.js";
5
7
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
6
8
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
7
9
  import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
@@ -74,6 +76,7 @@ export class AmplifyAppShellStack extends Stack {
74
76
  return;
75
77
  }
76
78
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
79
+ const emailEnvironment = addCloudEmail(this, parseCloudEmail(config.email, true, config.siteId));
77
80
  const stagingEnabled = isStagingEnabled(config);
78
81
  const stagingDomainName = resolveStagingDomainName(config);
79
82
  const cmsHostName = resolveCmsHostName(config);
@@ -195,6 +198,7 @@ export class AmplifyAppShellStack extends Stack {
195
198
  enablePerformanceMode: false,
196
199
  environmentVariables: environmentVariables({
197
200
  ...config.cms.environment,
201
+ ...emailEnvironment,
198
202
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
199
203
  ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
200
204
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
@@ -3,6 +3,8 @@ import { ArnFormat, CfnOutput, Duration, Fn, Stack, StackProps } from "aws-cdk-l
3
3
  import * as iam from "aws-cdk-lib/aws-iam";
4
4
  import * as amplify from "aws-cdk-lib/aws-amplify";
5
5
  import { SecureStringParameter } from "./secure-string-parameter";
6
+ import { addCloudEmail } from "./cloud-email";
7
+ import { parseCloudEmail } from "./cloud-email-config";
6
8
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
7
9
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
8
10
  import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
@@ -79,6 +81,7 @@ export class AmplifyAppShellStack extends Stack {
79
81
  }
80
82
 
81
83
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
84
+ const emailEnvironment = addCloudEmail(this, parseCloudEmail(config.email, true, config.siteId)!);
82
85
  const stagingEnabled = isStagingEnabled(config);
83
86
  const stagingDomainName = resolveStagingDomainName(config);
84
87
  const cmsHostName = resolveCmsHostName(config);
@@ -217,6 +220,7 @@ export class AmplifyAppShellStack extends Stack {
217
220
  enablePerformanceMode: false,
218
221
  environmentVariables: environmentVariables({
219
222
  ...config.cms.environment,
223
+ ...emailEnvironment,
220
224
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
221
225
  ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
222
226
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
@@ -0,0 +1,32 @@
1
+ export function parseCloudEmail(raw, cloud, siteId) {
2
+ if (!cloud) {
3
+ if (raw !== undefined)
4
+ throw new Error("site.json: email requires a cloud cms; static-only sites do not need email resources");
5
+ return undefined;
6
+ }
7
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
8
+ throw new Error("site.json: email is required for cloud sites; configure mode managed or existing and an explicit sendingDomain (see docs/email-reference-intake.md)");
9
+ }
10
+ const value = raw;
11
+ for (const key of Object.keys(value)) {
12
+ if (!["mode", "sendingDomain", "senderLocalPart", "configurationSet", "eventTopicName"].includes(key)) {
13
+ throw new Error(`site.json: email.${key} is not a known field`);
14
+ }
15
+ }
16
+ if (value.mode !== "managed" && value.mode !== "existing")
17
+ throw new Error("site.json: email.mode must be managed or existing");
18
+ const string = (key, pattern, fallback) => {
19
+ const result = value[key] ?? fallback;
20
+ if (typeof result !== "string" || !pattern.test(result))
21
+ throw new Error(`site.json: email.${key} is required and must be valid`);
22
+ return result;
23
+ };
24
+ return {
25
+ mode: value.mode,
26
+ sendingDomain: string("sendingDomain", /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/),
27
+ senderLocalPart: string("senderLocalPart", /^[a-z0-9][a-z0-9._+-]{0,63}$/, "notifications"),
28
+ configurationSet: string("configurationSet", /^[A-Za-z0-9_-]{1,64}$/, value.mode === "managed" ? `papyrus-${siteId}` : undefined),
29
+ // Preserve the newsletter consumer's historical default; the topic now also serves notifications.
30
+ eventTopicName: string("eventTopicName", /^[A-Za-z0-9_-]{1,256}$/, value.mode === "managed" ? `papyrus-newsletter-events-${siteId}` : undefined),
31
+ };
32
+ }
@@ -0,0 +1,39 @@
1
+ /** Email belongs to a cloud site, independently of any newsletter plugin. */
2
+ export type CloudEmailConfig = {
3
+ mode: "managed" | "existing";
4
+ sendingDomain: string;
5
+ senderLocalPart: string;
6
+ configurationSet: string;
7
+ eventTopicName: string;
8
+ };
9
+
10
+ export function parseCloudEmail(raw: unknown, cloud: boolean, siteId: string): CloudEmailConfig | undefined {
11
+ if (!cloud) {
12
+ if (raw !== undefined) throw new Error("site.json: email requires a cloud cms; static-only sites do not need email resources");
13
+ return undefined;
14
+ }
15
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
16
+ throw new Error("site.json: email is required for cloud sites; configure mode managed or existing and an explicit sendingDomain (see docs/email-reference-intake.md)");
17
+ }
18
+ const value = raw as Record<string, unknown>;
19
+ for (const key of Object.keys(value)) {
20
+ if (!["mode", "sendingDomain", "senderLocalPart", "configurationSet", "eventTopicName"].includes(key)) {
21
+ throw new Error(`site.json: email.${key} is not a known field`);
22
+ }
23
+ }
24
+ if (value.mode !== "managed" && value.mode !== "existing") throw new Error("site.json: email.mode must be managed or existing");
25
+ const string = (key: string, pattern: RegExp, fallback?: string) => {
26
+ const result = value[key] ?? fallback;
27
+ if (typeof result !== "string" || !pattern.test(result)) throw new Error(`site.json: email.${key} is required and must be valid`);
28
+ return result;
29
+ };
30
+ return {
31
+ mode: value.mode,
32
+ sendingDomain: string("sendingDomain", /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/),
33
+ senderLocalPart: string("senderLocalPart", /^[a-z0-9][a-z0-9._+-]{0,63}$/, "notifications"),
34
+ configurationSet: string("configurationSet", /^[A-Za-z0-9_-]{1,64}$/, value.mode === "managed" ? `papyrus-${siteId}` : undefined),
35
+ // Preserve the newsletter consumer's historical default; the topic now also serves notifications.
36
+ eventTopicName: string("eventTopicName", /^[A-Za-z0-9_-]{1,256}$/, value.mode === "managed" ? `papyrus-newsletter-events-${siteId}` : undefined),
37
+ };
38
+ }
39
+
@@ -0,0 +1,57 @@
1
+ import { ArnFormat, CfnOutput, RemovalPolicy } from "aws-cdk-lib";
2
+ import * as iam from "aws-cdk-lib/aws-iam";
3
+ import * as ses from "aws-cdk-lib/aws-ses";
4
+ import * as sns from "aws-cdk-lib/aws-sns";
5
+ /** No DNS mutation, sending grant or receipt activation. Existing identities remain with their owner. */
6
+ export function addCloudEmail(stack, config) {
7
+ if (config.mode === "managed") {
8
+ const configurationSet = new ses.CfnConfigurationSet(stack, "CloudEmailConfigurationSet", {
9
+ name: config.configurationSet,
10
+ reputationOptions: { reputationMetricsEnabled: true },
11
+ suppressionOptions: { suppressedReasons: ["BOUNCE", "COMPLAINT"] },
12
+ sendingOptions: { sendingEnabled: true },
13
+ });
14
+ configurationSet.applyRemovalPolicy(RemovalPolicy.RETAIN);
15
+ const identity = new ses.CfnEmailIdentity(stack, "CloudEmailIdentity", {
16
+ emailIdentity: config.sendingDomain,
17
+ dkimSigningAttributes: { nextSigningKeyLength: "RSA_2048_BIT" },
18
+ configurationSetAttributes: { configurationSetName: configurationSet.ref },
19
+ });
20
+ identity.applyRemovalPolicy(RemovalPolicy.RETAIN);
21
+ const topic = new sns.Topic(stack, "CloudEmailEvents", { topicName: config.eventTopicName });
22
+ topic.applyRemovalPolicy(RemovalPolicy.RETAIN);
23
+ topic.addToResourcePolicy(new iam.PolicyStatement({
24
+ principals: [new iam.ServicePrincipal("ses.amazonaws.com")], actions: ["sns:Publish"],
25
+ resources: [topic.topicArn], conditions: { StringEquals: { "aws:SourceAccount": stack.account } },
26
+ }));
27
+ const events = new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailEventDestination", {
28
+ configurationSetName: configurationSet.ref,
29
+ eventDestination: { name: "delivery-events", enabled: true,
30
+ matchingEventTypes: ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT", "REJECT", "RENDERING_FAILURE", "DELIVERY_DELAY"],
31
+ snsDestination: { topicArn: topic.topicArn } },
32
+ });
33
+ events.node.addDependency(topic);
34
+ // These dimensions also feed the newsletter consumer's existing bounce/complaint alarms.
35
+ new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailMetrics", {
36
+ configurationSetName: configurationSet.ref,
37
+ eventDestination: { name: "cloudwatch-metrics", enabled: true,
38
+ matchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
39
+ cloudWatchDestination: { dimensionConfigurations: [{ dimensionName: "ses:configuration-set",
40
+ dimensionValueSource: "MESSAGE_TAG", defaultDimensionValue: config.configurationSet }] } },
41
+ });
42
+ const records = [[identity.attrDkimDnsTokenName1, identity.attrDkimDnsTokenValue1],
43
+ [identity.attrDkimDnsTokenName2, identity.attrDkimDnsTokenValue2],
44
+ [identity.attrDkimDnsTokenName3, identity.attrDkimDnsTokenValue3]];
45
+ records.forEach(([name, value], index) => {
46
+ new CfnOutput(stack, `EmailDkimRecord${index + 1}Name`, { value: name, description: "CNAME name; DNS change requires separate approval" });
47
+ new CfnOutput(stack, `EmailDkimRecord${index + 1}Value`, { value });
48
+ });
49
+ }
50
+ const identityArn = stack.formatArn({ service: "ses", resource: "identity", resourceName: config.sendingDomain, arnFormat: ArnFormat.SLASH_RESOURCE_NAME });
51
+ new CfnOutput(stack, "EmailIdentityArn", { value: identityArn });
52
+ new CfnOutput(stack, "EmailConfigurationSetName", { value: config.configurationSet });
53
+ new CfnOutput(stack, "EmailEventTopicName", { value: config.eventTopicName });
54
+ return { PAPYRUS_EMAIL_FROM_ADDRESS: `${config.senderLocalPart}@${config.sendingDomain}`,
55
+ PAPYRUS_EMAIL_IDENTITY_ARN: identityArn, PAPYRUS_EMAIL_CONFIGURATION_SET: config.configurationSet,
56
+ PAPYRUS_EMAIL_EVENT_TOPIC_NAME: config.eventTopicName };
57
+ }
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import { App } from "aws-cdk-lib";
3
+ import { Template } from "aws-cdk-lib/assertions";
4
+ import { AmplifyAppShellStack } from "./amplify-app-shell.js";
5
+ import { parseSiteConfig } from "./site-config.js";
6
+ const base = () => ({
7
+ siteId: "example", repository: "https://github.com/example/publication", brand: "example",
8
+ frontend: "markus-static", papyrusVersion: "5.32.0", autoDeploy: false,
9
+ cms: { cognitoDomainPrefix: "example-cms", staging: false,
10
+ environment: { PAPYRUS_OAUTH_REDIRECT_URLS: "http://localhost:3001/" } },
11
+ });
12
+ function synth(raw) {
13
+ const app = new App();
14
+ return Template.fromStack(new AmplifyAppShellStack(app, "EmailTest", parseSiteConfig(raw), {
15
+ env: { account: "123456789012", region: "us-east-1" },
16
+ }));
17
+ }
18
+ const checks = {
19
+ managed() {
20
+ const template = synth({ ...base(), email: { mode: "managed", sendingDomain: "news.example.test" } });
21
+ template.resourceCountIs("AWS::SES::EmailIdentity", 1);
22
+ template.resourceCountIs("AWS::SES::ConfigurationSet", 1);
23
+ template.resourceCountIs("AWS::Route53::RecordSet", 0);
24
+ template.resourceCountIs("AWS::SES::ReceiptRuleSet", 0);
25
+ template.hasResourceProperties("AWS::SES::EmailIdentity", { EmailIdentity: "news.example.test" });
26
+ template.hasResourceProperties("AWS::SNS::Topic", { TopicName: "papyrus-newsletter-events-example" });
27
+ template.hasResourceProperties("AWS::SES::ConfigurationSetEventDestination", {
28
+ EventDestination: { Name: "cloudwatch-metrics", MatchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
29
+ CloudWatchDestination: { DimensionConfigurations: [{ DimensionName: "ses:configuration-set",
30
+ DimensionValueSource: "MESSAGE_TAG", DefaultDimensionValue: "papyrus-example" }] } },
31
+ });
32
+ const text = JSON.stringify(template.toJSON());
33
+ assert.ok(text.includes("PAPYRUS_EMAIL_FROM_ADDRESS"));
34
+ assert.ok(text.includes("notifications@news.example.test"));
35
+ assert.ok(text.includes("EmailDkimRecord1Name"));
36
+ assert.ok(!text.includes("ses:SendEmail"), "identity setup does not grant unsolicited sending authority");
37
+ },
38
+ existing() {
39
+ const template = synth({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test",
40
+ configurationSet: "already-owned", eventTopicName: "already-owned-events" } });
41
+ template.resourceCountIs("AWS::SES::EmailIdentity", 0);
42
+ template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
43
+ template.resourceCountIs("AWS::SNS::Topic", 0);
44
+ assert.ok(JSON.stringify(template.toJSON()).includes("already-owned"));
45
+ },
46
+ static() {
47
+ const raw = { ...base(), reader: { buildCommand: "make site", baseDirectory: "dist" } };
48
+ delete raw.cms;
49
+ const template = synth(raw);
50
+ template.resourceCountIs("AWS::SES::EmailIdentity", 0);
51
+ template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
52
+ template.resourceCountIs("AWS::SES::ReceiptRule", 0);
53
+ },
54
+ missing() {
55
+ assert.throws(() => parseSiteConfig(base()), /email.*required.*cloud/i);
56
+ assert.throws(() => parseSiteConfig({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test" } }), /email.configurationSet/);
57
+ },
58
+ };
59
+ export function runCloudEmailTests(selected) {
60
+ if (selected) {
61
+ assert.ok(checks[selected], selected);
62
+ checks[selected]();
63
+ }
64
+ else
65
+ for (const check of Object.values(checks))
66
+ check();
67
+ console.log("cloud email infrastructure: ok");
68
+ }
69
+ runCloudEmailTests(process.argv[2]);
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import { App } from "aws-cdk-lib";
3
+ import { Template } from "aws-cdk-lib/assertions";
4
+ import { AmplifyAppShellStack } from "./amplify-app-shell";
5
+ import { parseSiteConfig } from "./site-config";
6
+
7
+ const base = () => ({
8
+ siteId: "example", repository: "https://github.com/example/publication", brand: "example",
9
+ frontend: "markus-static", papyrusVersion: "5.32.0", autoDeploy: false,
10
+ cms: { cognitoDomainPrefix: "example-cms", staging: false,
11
+ environment: { PAPYRUS_OAUTH_REDIRECT_URLS: "http://localhost:3001/" } },
12
+ });
13
+ function synth(raw: unknown) {
14
+ const app = new App();
15
+ return Template.fromStack(new AmplifyAppShellStack(app, "EmailTest", parseSiteConfig(raw), {
16
+ env: { account: "123456789012", region: "us-east-1" },
17
+ }));
18
+ }
19
+ const checks: Record<string, () => void> = {
20
+ managed() {
21
+ const template = synth({ ...base(), email: { mode: "managed", sendingDomain: "news.example.test" } });
22
+ template.resourceCountIs("AWS::SES::EmailIdentity", 1);
23
+ template.resourceCountIs("AWS::SES::ConfigurationSet", 1);
24
+ template.resourceCountIs("AWS::Route53::RecordSet", 0);
25
+ template.resourceCountIs("AWS::SES::ReceiptRuleSet", 0);
26
+ template.hasResourceProperties("AWS::SES::EmailIdentity", { EmailIdentity: "news.example.test" });
27
+ template.hasResourceProperties("AWS::SNS::Topic", { TopicName: "papyrus-newsletter-events-example" });
28
+ template.hasResourceProperties("AWS::SES::ConfigurationSetEventDestination", {
29
+ EventDestination: { Name: "cloudwatch-metrics", MatchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
30
+ CloudWatchDestination: { DimensionConfigurations: [{ DimensionName: "ses:configuration-set",
31
+ DimensionValueSource: "MESSAGE_TAG", DefaultDimensionValue: "papyrus-example" }] } },
32
+ });
33
+ const text = JSON.stringify(template.toJSON());
34
+ assert.ok(text.includes("PAPYRUS_EMAIL_FROM_ADDRESS"));
35
+ assert.ok(text.includes("notifications@news.example.test"));
36
+ assert.ok(text.includes("EmailDkimRecord1Name"));
37
+ assert.ok(!text.includes("ses:SendEmail"), "identity setup does not grant unsolicited sending authority");
38
+ },
39
+ existing() {
40
+ const template = synth({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test",
41
+ configurationSet: "already-owned", eventTopicName: "already-owned-events" } });
42
+ template.resourceCountIs("AWS::SES::EmailIdentity", 0);
43
+ template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
44
+ template.resourceCountIs("AWS::SNS::Topic", 0);
45
+ assert.ok(JSON.stringify(template.toJSON()).includes("already-owned"));
46
+ },
47
+ static() {
48
+ const raw: Record<string, unknown> = { ...base(), reader: { buildCommand: "make site", baseDirectory: "dist" } };
49
+ delete raw.cms;
50
+ const template = synth(raw);
51
+ template.resourceCountIs("AWS::SES::EmailIdentity", 0);
52
+ template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
53
+ template.resourceCountIs("AWS::SES::ReceiptRule", 0);
54
+ },
55
+ missing() {
56
+ assert.throws(() => parseSiteConfig(base()), /email.*required.*cloud/i);
57
+ assert.throws(() => parseSiteConfig({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test" } }), /email.configurationSet/);
58
+ },
59
+ };
60
+ export function runCloudEmailTests(selected?: string): void {
61
+ if (selected) { assert.ok(checks[selected], selected); checks[selected](); }
62
+ else for (const check of Object.values(checks)) check();
63
+ console.log("cloud email infrastructure: ok");
64
+ }
65
+
66
+ runCloudEmailTests(process.argv[2]);
@@ -0,0 +1,60 @@
1
+ import { ArnFormat, CfnOutput, RemovalPolicy, Stack } from "aws-cdk-lib";
2
+ import * as iam from "aws-cdk-lib/aws-iam";
3
+ import * as ses from "aws-cdk-lib/aws-ses";
4
+ import * as sns from "aws-cdk-lib/aws-sns";
5
+
6
+ import type { CloudEmailConfig } from "./cloud-email-config";
7
+
8
+ /** No DNS mutation, sending grant or receipt activation. Existing identities remain with their owner. */
9
+ export function addCloudEmail(stack: Stack, config: CloudEmailConfig): Record<string, string> {
10
+ if (config.mode === "managed") {
11
+ const configurationSet = new ses.CfnConfigurationSet(stack, "CloudEmailConfigurationSet", {
12
+ name: config.configurationSet,
13
+ reputationOptions: { reputationMetricsEnabled: true },
14
+ suppressionOptions: { suppressedReasons: ["BOUNCE", "COMPLAINT"] },
15
+ sendingOptions: { sendingEnabled: true },
16
+ });
17
+ configurationSet.applyRemovalPolicy(RemovalPolicy.RETAIN);
18
+ const identity = new ses.CfnEmailIdentity(stack, "CloudEmailIdentity", {
19
+ emailIdentity: config.sendingDomain,
20
+ dkimSigningAttributes: { nextSigningKeyLength: "RSA_2048_BIT" },
21
+ configurationSetAttributes: { configurationSetName: configurationSet.ref },
22
+ });
23
+ identity.applyRemovalPolicy(RemovalPolicy.RETAIN);
24
+ const topic = new sns.Topic(stack, "CloudEmailEvents", { topicName: config.eventTopicName });
25
+ topic.applyRemovalPolicy(RemovalPolicy.RETAIN);
26
+ topic.addToResourcePolicy(new iam.PolicyStatement({
27
+ principals: [new iam.ServicePrincipal("ses.amazonaws.com")], actions: ["sns:Publish"],
28
+ resources: [topic.topicArn], conditions: { StringEquals: { "aws:SourceAccount": stack.account } },
29
+ }));
30
+ const events = new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailEventDestination", {
31
+ configurationSetName: configurationSet.ref,
32
+ eventDestination: { name: "delivery-events", enabled: true,
33
+ matchingEventTypes: ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT", "REJECT", "RENDERING_FAILURE", "DELIVERY_DELAY"],
34
+ snsDestination: { topicArn: topic.topicArn } },
35
+ });
36
+ events.node.addDependency(topic);
37
+ // These dimensions also feed the newsletter consumer's existing bounce/complaint alarms.
38
+ new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailMetrics", {
39
+ configurationSetName: configurationSet.ref,
40
+ eventDestination: { name: "cloudwatch-metrics", enabled: true,
41
+ matchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
42
+ cloudWatchDestination: { dimensionConfigurations: [{ dimensionName: "ses:configuration-set",
43
+ dimensionValueSource: "MESSAGE_TAG", defaultDimensionValue: config.configurationSet }] } },
44
+ });
45
+ const records = [[identity.attrDkimDnsTokenName1, identity.attrDkimDnsTokenValue1],
46
+ [identity.attrDkimDnsTokenName2, identity.attrDkimDnsTokenValue2],
47
+ [identity.attrDkimDnsTokenName3, identity.attrDkimDnsTokenValue3]];
48
+ records.forEach(([name, value], index) => {
49
+ new CfnOutput(stack, `EmailDkimRecord${index + 1}Name`, { value: name, description: "CNAME name; DNS change requires separate approval" });
50
+ new CfnOutput(stack, `EmailDkimRecord${index + 1}Value`, { value });
51
+ });
52
+ }
53
+ const identityArn = stack.formatArn({ service: "ses", resource: "identity", resourceName: config.sendingDomain, arnFormat: ArnFormat.SLASH_RESOURCE_NAME });
54
+ new CfnOutput(stack, "EmailIdentityArn", { value: identityArn });
55
+ new CfnOutput(stack, "EmailConfigurationSetName", { value: config.configurationSet });
56
+ new CfnOutput(stack, "EmailEventTopicName", { value: config.eventTopicName });
57
+ return { PAPYRUS_EMAIL_FROM_ADDRESS: `${config.senderLocalPart}@${config.sendingDomain}`,
58
+ PAPYRUS_EMAIL_IDENTITY_ARN: identityArn, PAPYRUS_EMAIL_CONFIGURATION_SET: config.configurationSet,
59
+ PAPYRUS_EMAIL_EVENT_TOPIC_NAME: config.eventTopicName };
60
+ }
@@ -1,3 +1,4 @@
1
+ import { parseCloudEmail } from "./cloud-email-config.js";
1
2
  export const READER_REDIRECT_STATUSES = ["301", "302", "200", "404", "404-200"];
2
3
  export function hasCmsApp(config) {
3
4
  return config.cms !== undefined;
@@ -35,7 +36,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
35
36
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
36
37
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
37
38
  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_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
39
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_EMAIL_FROM_ADDRESS", "PAPYRUS_EMAIL_IDENTITY_ARN", "PAPYRUS_EMAIL_CONFIGURATION_SET", "PAPYRUS_EMAIL_EVENT_TOPIC_NAME", "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
40
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
40
41
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
41
42
  "GET/v1/identities/me",
@@ -350,7 +351,7 @@ function parseAws(value) {
350
351
  }
351
352
  export function parseSiteConfig(raw) {
352
353
  const record = requireObject(raw, "site");
353
- rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
354
+ rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
354
355
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
355
356
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
356
357
  const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
@@ -460,6 +461,7 @@ export function parseSiteConfig(raw) {
460
461
  stackName,
461
462
  aws,
462
463
  cms,
464
+ email: parseCloudEmail(record.email, cms !== undefined, siteId),
463
465
  reader,
464
466
  papyrusVersion,
465
467
  storagePreviewPrefix,
@@ -1,3 +1,5 @@
1
+ import { parseCloudEmail, type CloudEmailConfig } from "./cloud-email-config";
2
+
1
3
  export type CmsRedirect = {
2
4
  source: string;
3
5
  status: 301 | 302;
@@ -63,6 +65,8 @@ export type AmplifyAppShellSiteConfig = {
63
65
  stackName?: string;
64
66
  aws?: SiteAwsConfig;
65
67
  cms?: CmsConfig;
68
+ /** Required for cloud CMS sites; independent of newsletter feature enablement. */
69
+ email?: CloudEmailConfig;
66
70
  reader?: {
67
71
  appName?: string;
68
72
  domainName?: string;
@@ -157,7 +161,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
157
161
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
158
162
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
159
163
  const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
160
- 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"];
164
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_EMAIL_FROM_ADDRESS", "PAPYRUS_EMAIL_IDENTITY_ARN", "PAPYRUS_EMAIL_CONFIGURATION_SET", "PAPYRUS_EMAIL_EVENT_TOPIC_NAME", "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"];
161
165
 
162
166
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
163
167
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
@@ -490,7 +494,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
490
494
  const record = requireObject(raw, "site");
491
495
  rejectUnknownKeys(
492
496
  record,
493
- ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"],
497
+ ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"],
494
498
  "site",
495
499
  );
496
500
 
@@ -600,6 +604,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
600
604
  stackName,
601
605
  aws,
602
606
  cms,
607
+ email: parseCloudEmail(record.email, cms !== undefined, siteId),
603
608
  reader,
604
609
  papyrusVersion,
605
610
  storagePreviewPrefix,
@@ -1,5 +1,4 @@
1
1
  import type { CategorySteeringDashboard } from "./category-repository";
2
- import { createEmptyCategorySteeringDashboard } from "./category-dashboard";
3
2
  import type { ReaderAuthSnapshot } from "../components/reader-auth-state";
4
3
 
5
4
  export type NewsDeskShellPhase =
@@ -59,26 +58,6 @@ export function beginDeskLoad(state: NewsDeskShellState, auth: ReaderAuthSnapsho
59
58
  };
60
59
  }
61
60
 
62
- export function beginAuthenticatedDeskHydration(
63
- state: NewsDeskShellState,
64
- auth: ReaderAuthSnapshot,
65
- options: { canManageUsers: boolean },
66
- ): NewsDeskShellState {
67
- const dashboard = state.dashboard ?? {
68
- ...createEmptyCategorySteeringDashboard(),
69
- isPublicSkeleton: false,
70
- canManageUsers: options.canManageUsers,
71
- };
72
- return {
73
- ...state,
74
- phase: state.dashboard ? "refreshing" : "loadingDesk",
75
- auth,
76
- dashboard,
77
- stale: false,
78
- error: null,
79
- };
80
- }
81
-
82
61
  export function resolveSignedOut(state: NewsDeskShellState, auth: ReaderAuthSnapshot): NewsDeskShellState {
83
62
  return {
84
63
  ...state,
@@ -143,6 +122,18 @@ export function resolveDashboardFailure(
143
122
  };
144
123
  }
145
124
 
125
+ /** An unverified session must never leave private records on screen. */
126
+ export function resolveAccessFailure(state: NewsDeskShellState, error: string): NewsDeskShellState {
127
+ return {
128
+ ...state,
129
+ phase: "error",
130
+ auth: { status: "signedOut", label: "Signed out" },
131
+ dashboard: null,
132
+ stale: false,
133
+ error,
134
+ };
135
+ }
136
+
146
137
  export function patchDashboard(
147
138
  state: NewsDeskShellState,
148
139
  patch: (dashboard: CategorySteeringDashboard) => CategorySteeringDashboard,
@@ -8,6 +8,7 @@ export type OrganizerDestination = {
8
8
  };
9
9
  export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
10
10
  { title: "Editorial work", items: [
11
+ { key: "overview", label: "Overview", description: "See notifications and work needing attention.", path: "/newsroom", primary: true },
11
12
  { key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
12
13
  { key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
13
14
  { key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
@@ -23,7 +24,7 @@ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly Org
23
24
  ] },
24
25
  ];
25
26
  const all = ORGANIZER_NAV_GROUPS.flatMap(group => group.items);
26
- export const ORGANIZER_PRIMARY_NAV = ["articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
27
+ export const ORGANIZER_PRIMARY_NAV = ["overview", "articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
27
28
 
28
29
  export const ORGANIZER_SETTINGS_DESTINATION = {
29
30
  label: "Settings", path: "/newsroom/settings", description: "Theme, motion and interface preferences.",
package/package.json CHANGED
@@ -108,6 +108,7 @@
108
108
  "./components/newsroom-curation-provider": "./components/newsroom-curation-provider.tsx",
109
109
  "./components/newsroom-ops-overview": "./components/newsroom-ops-overview.tsx",
110
110
  "./components/newsroom-ops-shell": "./components/newsroom-ops-shell.tsx",
111
+ "./components/newsroom-overview-skeleton": "./components/newsroom-overview-skeleton.tsx",
111
112
  "./components/newsroom-references-view": "./components/newsroom-references-view.tsx",
112
113
  "./components/newsroom-theme-fonts/broadsheet-green": "./components/newsroom-theme-fonts/broadsheet-green.tsx",
113
114
  "./components/newsroom-theme-fonts/newspaper-gothic": "./components/newsroom-theme-fonts/newspaper-gothic.tsx",
@@ -447,7 +448,7 @@
447
448
  "optional": true
448
449
  }
449
450
  },
450
- "version": "5.33.2",
451
+ "version": "5.35.0",
451
452
  "dependencies": {
452
453
  "@aws-amplify/adapter-nextjs": "^1.7.3",
453
454
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.33.2",
2
+ "version": "5.35.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",