@anthusai/papyrus 2.0.0-next.11 → 2.0.0-next.110
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/auth/resource.js +16 -1
- package/amplify/auth/resource.ts +19 -1
- package/amplify/data/function-handler.js +20 -0
- package/amplify/data/function-handler.ts +40 -0
- package/amplify/data/resource.js +152 -62
- package/amplify/data/resource.ts +158 -62
- package/amplify/data/schema.js +2101 -1987
- package/amplify/data/schema.ts +197 -70
- package/amplify/functions/assignment-action/data-access.js +2 -0
- package/amplify/functions/assignment-action/data-access.ts +2 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +26 -0
- package/amplify/functions/automation-view/schema.ts +28 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/functions/content-actions/handler.py +64 -3
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/editorial-checks/handler.py +2 -2
- package/amplify/functions/editorial-checks/requirements.txt +1 -1
- package/amplify/functions/editorial-checks/resource.js +3 -3
- package/amplify/functions/editorial-checks/resource.ts +3 -3
- package/amplify/functions/editorial-checks/schema.js +38 -8
- package/amplify/functions/editorial-checks/schema.ts +38 -8
- package/amplify/functions/editorial-checks/wire.js +4 -0
- package/amplify/functions/editorial-checks/wire.ts +4 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +29 -0
- package/amplify/functions/monitoring-view/schema.ts +31 -0
- package/amplify/functions/monitoring-view/wire.js +64 -0
- package/amplify/functions/monitoring-view/wire.ts +81 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +57 -0
- package/amplify/plugins/newsletter/schema.ts +62 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +182 -0
- package/amplify/plugins/reader-steering/config.ts +290 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1589 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +487 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +187 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +213 -0
- package/amplify/plugins/reader-steering/schema.js +252 -0
- package/amplify/plugins/reader-steering/schema.ts +254 -0
- package/amplify/plugins/registry.js +4 -0
- package/amplify/plugins/registry.ts +4 -0
- package/amplify/plugins/signup/schema.js +2 -1
- package/amplify/plugins/signup/schema.ts +2 -1
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.js +19 -0
- package/amplify/site-backend-config.ts +45 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +42 -4
- package/amplify/site-backend.ts +45 -4
- package/app/.well-known/papyrus-cli.json/route.ts +9 -0
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/chat/[...path]/route.ts +17 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/app/layout.tsx +5 -1
- package/bin/papyrus-infra.mjs +20 -1
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +195 -0
- package/components/editorial-checks-admin-panel.tsx +32 -17
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +422 -0
- package/components/monitoring-panel.tsx +60 -0
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-access-gate.tsx +16 -2
- package/components/newsroom-article-editor.tsx +9 -7
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-assignment-detail.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-overview.tsx +26 -96
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +33 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +192 -49
- package/components/notification-center.tsx +281 -0
- package/components/papyrus-console-conversation.css +14 -0
- package/components/papyrus-console-conversation.tsx +55 -0
- package/components/papyrus-console-shell.tsx +62 -420
- package/components/papyrus-private-chat-panel.tsx +134 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +10 -6
- package/components/reference-priming.tsx +14 -10
- package/components/reference-relevance-review.tsx +3 -3
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +230 -144
- package/infra/amplify-app-shell.js +96 -16
- package/infra/amplify-app-shell.ts +108 -18
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +92 -6
- package/infra/site-config.ts +138 -6
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +252 -0
- package/lib/category-repository.ts +14 -12
- package/lib/cli-login-discovery.ts +78 -0
- package/lib/console-web-context.ts +1 -1
- package/lib/curation-routes.ts +345 -0
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/editorial-checks-client.ts +18 -0
- package/lib/editorial-checks-demo.ts +1 -1
- package/lib/editorial-checks.ts +8 -4
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +83 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +191 -0
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsroom-demo-dashboard.ts +1 -1
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/papyrus-chat-admission.ts +65 -0
- package/lib/papyrus-hosted-chat.ts +217 -0
- package/lib/papyrus-private-chat-client.ts +81 -0
- package/lib/prospectus-session.ts +30 -14
- package/lib/prospectus-sign-in-routes.ts +106 -31
- package/lib/prospectus-site-identity.ts +46 -6
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/relevance-status.ts +2 -2
- package/lib/review-client.ts +120 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +246 -0
- package/lib/semantic-graph.ts +2 -0
- package/lib/shared-chat-admission.ts +182 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin-value.ts +50 -0
- package/lib/site-origin.ts +8 -0
- package/lib/use-reference-review-data.ts +44 -0
- package/middleware.ts +14 -3
- package/package.json +67 -3
- package/routes.manifest.json +238 -2
- package/src/sync.mjs +11 -2
- package/src/with-papyrus.d.mts +1 -0
- package/src/with-papyrus.mjs +17 -1
|
@@ -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
|
+
}
|
package/infra/index.js
CHANGED
|
@@ -10,6 +10,7 @@ try {
|
|
|
10
10
|
);
|
|
11
11
|
}
|
|
12
12
|
export const { AmplifyAppShellStack } = await import("./amplify-app-shell.js");
|
|
13
|
+
export const { SecureStringParameter } = await import("./secure-string-parameter.js");
|
|
13
14
|
export const { GithubOidcProviderStack } = await import("./github-oidc-provider.js");
|
|
14
15
|
export { parseSiteConfig } from "./site-config.js";
|
|
15
16
|
export { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec, buildSpecFor } from "./build-specs.js";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source of the Lambda behind SecureStringParameter, as a string so the staged
|
|
3
|
+
* package (which ships only .ts under infra/) can inline it and tests can run it
|
|
4
|
+
* against a fake SSM client. CommonJS, under the 4096-character inline limit.
|
|
5
|
+
*
|
|
6
|
+
* It creates the parameter when it is missing and does nothing else: it never
|
|
7
|
+
* overwrites an existing value, never reads one back, never logs the value, and
|
|
8
|
+
* returns no data (only the parameter name as the physical id).
|
|
9
|
+
*/
|
|
10
|
+
export const SECURE_STRING_PARAMETER_HANDLER_SOURCE = `
|
|
11
|
+
const { randomInt } = require("node:crypto");
|
|
12
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
13
|
+
function generate(length) {
|
|
14
|
+
let value = "";
|
|
15
|
+
for (let index = 0; index < length; index += 1) value += ALPHABET[randomInt(ALPHABET.length)];
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
exports.createHandler = (ssm, PutParameterCommand) => async (event) => {
|
|
19
|
+
const name = event.ResourceProperties.ParameterName;
|
|
20
|
+
if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
|
|
21
|
+
try {
|
|
22
|
+
await ssm.send(new PutParameterCommand({
|
|
23
|
+
Name: name,
|
|
24
|
+
Description: event.ResourceProperties.Description,
|
|
25
|
+
Type: "SecureString",
|
|
26
|
+
Tier: "Standard",
|
|
27
|
+
Overwrite: false,
|
|
28
|
+
Value: generate(Number(event.ResourceProperties.Length)),
|
|
29
|
+
}));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (!error || error.name !== "ParameterAlreadyExists") {
|
|
32
|
+
throw new Error("PutParameter failed for " + name + " (" + (error && error.name ? error.name : "error") + ")");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { PhysicalResourceId: name };
|
|
36
|
+
};
|
|
37
|
+
exports.handler = async (event) => {
|
|
38
|
+
const { SSMClient, PutParameterCommand } = require("@aws-sdk/client-ssm");
|
|
39
|
+
return exports.createHandler(new SSMClient({}), PutParameterCommand)(event);
|
|
40
|
+
};
|
|
41
|
+
`;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source of the Lambda behind SecureStringParameter, as a string so the staged
|
|
3
|
+
* package (which ships only .ts under infra/) can inline it and tests can run it
|
|
4
|
+
* against a fake SSM client. CommonJS, under the 4096-character inline limit.
|
|
5
|
+
*
|
|
6
|
+
* It creates the parameter when it is missing and does nothing else: it never
|
|
7
|
+
* overwrites an existing value, never reads one back, never logs the value, and
|
|
8
|
+
* returns no data (only the parameter name as the physical id).
|
|
9
|
+
*/
|
|
10
|
+
export const SECURE_STRING_PARAMETER_HANDLER_SOURCE = `
|
|
11
|
+
const { randomInt } = require("node:crypto");
|
|
12
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
13
|
+
function generate(length) {
|
|
14
|
+
let value = "";
|
|
15
|
+
for (let index = 0; index < length; index += 1) value += ALPHABET[randomInt(ALPHABET.length)];
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
exports.createHandler = (ssm, PutParameterCommand) => async (event) => {
|
|
19
|
+
const name = event.ResourceProperties.ParameterName;
|
|
20
|
+
if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
|
|
21
|
+
try {
|
|
22
|
+
await ssm.send(new PutParameterCommand({
|
|
23
|
+
Name: name,
|
|
24
|
+
Description: event.ResourceProperties.Description,
|
|
25
|
+
Type: "SecureString",
|
|
26
|
+
Tier: "Standard",
|
|
27
|
+
Overwrite: false,
|
|
28
|
+
Value: generate(Number(event.ResourceProperties.Length)),
|
|
29
|
+
}));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (!error || error.name !== "ParameterAlreadyExists") {
|
|
32
|
+
throw new Error("PutParameter failed for " + name + " (" + (error && error.name ? error.name : "error") + ")");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { PhysicalResourceId: name };
|
|
36
|
+
};
|
|
37
|
+
exports.handler = async (event) => {
|
|
38
|
+
const { SSMClient, PutParameterCommand } = require("@aws-sdk/client-ssm");
|
|
39
|
+
return exports.createHandler(new SSMClient({}), PutParameterCommand)(event);
|
|
40
|
+
};
|
|
41
|
+
`;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Construct } from "constructs";
|
|
2
|
+
import { ArnFormat, CustomResource, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
3
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
4
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
5
|
+
import * as customResources from "aws-cdk-lib/custom-resources";
|
|
6
|
+
import { SECURE_STRING_PARAMETER_HANDLER_SOURCE } from "./secure-string-parameter-handler.js";
|
|
7
|
+
/** One Lambda and role per stack, shared by every SecureStringParameter in it. */
|
|
8
|
+
class SecureStringParameterProvider extends Construct {
|
|
9
|
+
static ID = "SecureStringParameterProvider";
|
|
10
|
+
role;
|
|
11
|
+
provider;
|
|
12
|
+
static of(stack) {
|
|
13
|
+
return stack.node.tryFindChild(SecureStringParameterProvider.ID)
|
|
14
|
+
?? new SecureStringParameterProvider(stack);
|
|
15
|
+
}
|
|
16
|
+
constructor(stack) {
|
|
17
|
+
super(stack, SecureStringParameterProvider.ID);
|
|
18
|
+
this.role = new iam.Role(this, "Role", {
|
|
19
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
20
|
+
description: "Creates generated SecureString parameters: PutParameter per named parameter, never overwriting",
|
|
21
|
+
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")],
|
|
22
|
+
});
|
|
23
|
+
const onEvent = new lambda.Function(this, "OnEvent", {
|
|
24
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
25
|
+
handler: "index.handler",
|
|
26
|
+
code: lambda.Code.fromInline(SECURE_STRING_PARAMETER_HANDLER_SOURCE),
|
|
27
|
+
role: this.role,
|
|
28
|
+
timeout: Duration.seconds(30),
|
|
29
|
+
description: "Creates a generated SecureString parameter once; never returns or logs the value",
|
|
30
|
+
});
|
|
31
|
+
this.provider = new customResources.Provider(this, "Provider", { onEventHandler: onEvent });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A generated secret in SSM Parameter Store as a standard SecureString (free,
|
|
36
|
+
* AWS-managed `aws/ssm` key), instead of a Secrets Manager secret ($0.40/month).
|
|
37
|
+
*
|
|
38
|
+
* CloudFormation cannot create a SecureString, so a custom resource Lambda puts
|
|
39
|
+
* it. The Lambda generates a random value at create time and never returns or
|
|
40
|
+
* logs it, so the value is in no template, output or stack event. It is
|
|
41
|
+
* create-only: an update never regenerates it and never overwrites a parameter
|
|
42
|
+
* that already exists (a hand-made one is adopted as it is), and delete retains
|
|
43
|
+
* it, so a stack teardown cannot sign anyone out. Rotate it with
|
|
44
|
+
* `aws ssm put-parameter --overwrite`. Readers get `ssm:GetParameter` on
|
|
45
|
+
* `parameterArn` only; decrypting with the AWS-managed key needs no KMS grant.
|
|
46
|
+
*/
|
|
47
|
+
export class SecureStringParameter extends Construct {
|
|
48
|
+
parameterName;
|
|
49
|
+
parameterArn;
|
|
50
|
+
constructor(scope, id, props) {
|
|
51
|
+
super(scope, id);
|
|
52
|
+
if (!props.parameterName.startsWith("/"))
|
|
53
|
+
throw new Error(`SSM parameter name must be absolute: ${props.parameterName}`);
|
|
54
|
+
const length = props.length ?? 64;
|
|
55
|
+
if (!Number.isInteger(length) || length < 32)
|
|
56
|
+
throw new Error("A generated secret must be at least 32 characters.");
|
|
57
|
+
const stack = Stack.of(this);
|
|
58
|
+
this.parameterName = props.parameterName;
|
|
59
|
+
this.parameterArn = stack.formatArn({
|
|
60
|
+
service: "ssm",
|
|
61
|
+
resource: "parameter",
|
|
62
|
+
resourceName: props.parameterName.slice(1),
|
|
63
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
64
|
+
});
|
|
65
|
+
const shared = SecureStringParameterProvider.of(stack);
|
|
66
|
+
shared.role.addToPolicy(new iam.PolicyStatement({
|
|
67
|
+
sid: `Put${id.replace(/[^A-Za-z0-9]/g, "")}`,
|
|
68
|
+
actions: ["ssm:PutParameter"],
|
|
69
|
+
resources: [this.parameterArn],
|
|
70
|
+
}));
|
|
71
|
+
const resource = new CustomResource(this, "Resource", {
|
|
72
|
+
serviceToken: shared.provider.serviceToken,
|
|
73
|
+
resourceType: "Custom::SecureStringParameter",
|
|
74
|
+
properties: { ParameterName: props.parameterName, Description: props.description, Length: String(length) },
|
|
75
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
76
|
+
});
|
|
77
|
+
resource.node.addDependency(shared.role);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Construct } from "constructs";
|
|
2
|
+
import { ArnFormat, CustomResource, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
3
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
4
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
5
|
+
import * as customResources from "aws-cdk-lib/custom-resources";
|
|
6
|
+
import { SECURE_STRING_PARAMETER_HANDLER_SOURCE } from "./secure-string-parameter-handler";
|
|
7
|
+
|
|
8
|
+
export interface SecureStringParameterProps {
|
|
9
|
+
/** Absolute parameter name, e.g. `/papyrus/<siteId>/prospectus-session-sealing`. */
|
|
10
|
+
parameterName: string;
|
|
11
|
+
description: string;
|
|
12
|
+
/** Length of the generated value. Default 64. */
|
|
13
|
+
length?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One Lambda and role per stack, shared by every SecureStringParameter in it. */
|
|
17
|
+
class SecureStringParameterProvider extends Construct {
|
|
18
|
+
static readonly ID = "SecureStringParameterProvider";
|
|
19
|
+
public readonly role: iam.Role;
|
|
20
|
+
public readonly provider: customResources.Provider;
|
|
21
|
+
|
|
22
|
+
static of(stack: Stack): SecureStringParameterProvider {
|
|
23
|
+
return (stack.node.tryFindChild(SecureStringParameterProvider.ID) as SecureStringParameterProvider | undefined)
|
|
24
|
+
?? new SecureStringParameterProvider(stack);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private constructor(stack: Stack) {
|
|
28
|
+
super(stack, SecureStringParameterProvider.ID);
|
|
29
|
+
this.role = new iam.Role(this, "Role", {
|
|
30
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
31
|
+
description: "Creates generated SecureString parameters: PutParameter per named parameter, never overwriting",
|
|
32
|
+
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")],
|
|
33
|
+
});
|
|
34
|
+
const onEvent = new lambda.Function(this, "OnEvent", {
|
|
35
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
36
|
+
handler: "index.handler",
|
|
37
|
+
code: lambda.Code.fromInline(SECURE_STRING_PARAMETER_HANDLER_SOURCE),
|
|
38
|
+
role: this.role,
|
|
39
|
+
timeout: Duration.seconds(30),
|
|
40
|
+
description: "Creates a generated SecureString parameter once; never returns or logs the value",
|
|
41
|
+
});
|
|
42
|
+
this.provider = new customResources.Provider(this, "Provider", { onEventHandler: onEvent });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A generated secret in SSM Parameter Store as a standard SecureString (free,
|
|
48
|
+
* AWS-managed `aws/ssm` key), instead of a Secrets Manager secret ($0.40/month).
|
|
49
|
+
*
|
|
50
|
+
* CloudFormation cannot create a SecureString, so a custom resource Lambda puts
|
|
51
|
+
* it. The Lambda generates a random value at create time and never returns or
|
|
52
|
+
* logs it, so the value is in no template, output or stack event. It is
|
|
53
|
+
* create-only: an update never regenerates it and never overwrites a parameter
|
|
54
|
+
* that already exists (a hand-made one is adopted as it is), and delete retains
|
|
55
|
+
* it, so a stack teardown cannot sign anyone out. Rotate it with
|
|
56
|
+
* `aws ssm put-parameter --overwrite`. Readers get `ssm:GetParameter` on
|
|
57
|
+
* `parameterArn` only; decrypting with the AWS-managed key needs no KMS grant.
|
|
58
|
+
*/
|
|
59
|
+
export class SecureStringParameter extends Construct {
|
|
60
|
+
public readonly parameterName: string;
|
|
61
|
+
public readonly parameterArn: string;
|
|
62
|
+
|
|
63
|
+
constructor(scope: Construct, id: string, props: SecureStringParameterProps) {
|
|
64
|
+
super(scope, id);
|
|
65
|
+
if (!props.parameterName.startsWith("/")) throw new Error(`SSM parameter name must be absolute: ${props.parameterName}`);
|
|
66
|
+
const length = props.length ?? 64;
|
|
67
|
+
if (!Number.isInteger(length) || length < 32) throw new Error("A generated secret must be at least 32 characters.");
|
|
68
|
+
const stack = Stack.of(this);
|
|
69
|
+
this.parameterName = props.parameterName;
|
|
70
|
+
this.parameterArn = stack.formatArn({
|
|
71
|
+
service: "ssm",
|
|
72
|
+
resource: "parameter",
|
|
73
|
+
resourceName: props.parameterName.slice(1),
|
|
74
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const shared = SecureStringParameterProvider.of(stack);
|
|
78
|
+
shared.role.addToPolicy(new iam.PolicyStatement({
|
|
79
|
+
sid: `Put${id.replace(/[^A-Za-z0-9]/g, "")}`,
|
|
80
|
+
actions: ["ssm:PutParameter"],
|
|
81
|
+
resources: [this.parameterArn],
|
|
82
|
+
}));
|
|
83
|
+
const resource = new CustomResource(this, "Resource", {
|
|
84
|
+
serviceToken: shared.provider.serviceToken,
|
|
85
|
+
resourceType: "Custom::SecureStringParameter",
|
|
86
|
+
properties: { ParameterName: props.parameterName, Description: props.description, Length: String(length) },
|
|
87
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
88
|
+
});
|
|
89
|
+
resource.node.addDependency(shared.role);
|
|
90
|
+
}
|
|
91
|
+
}
|