@anthusai/papyrus 2.0.0-next.8 → 2.0.0-next.80
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/data/function-handler.js +20 -0
- package/amplify/data/function-handler.ts +40 -0
- package/amplify/data/resource.js +151 -61
- package/amplify/data/resource.ts +157 -61
- package/amplify/data/schema.js +2096 -1980
- package/amplify/data/schema.ts +198 -69
- package/amplify/functions/assignment-action/data-access.js +14 -15
- package/amplify/functions/assignment-action/data-access.ts +13 -14
- 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 +61 -2
- 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/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +122 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -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 +17 -0
- package/amplify/functions/monitoring-view/schema.ts +19 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -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 +1554 -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 +255 -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 +186 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
- package/amplify/plugins/reader-steering/schema.js +205 -0
- package/amplify/plugins/reader-steering/schema.ts +207 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- 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.ts +18 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +41 -2
- package/amplify/site-backend.ts +46 -2
- 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/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/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 +192 -0
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -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 +15 -1
- package/components/newsroom-article-editor.tsx +6 -6
- package/components/newsroom-articles-view.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 +22 -92
- 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 +273 -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 +223 -144
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +94 -16
- package/infra/amplify-app-shell.ts +106 -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 +88 -6
- package/infra/site-config.ts +134 -6
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +232 -0
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -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-organizer-nav.ts +2 -1
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +84 -19
- package/lib/prospectus-site-identity.ts +24 -1
- 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 +99 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +238 -0
- package/lib/semantic-graph.ts +2 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/lib/use-reference-review-data.ts +44 -0
- package/middleware.ts +14 -3
- package/package.json +56 -3
- package/routes.manifest.json +215 -2
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -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
|
+
}
|
package/infra/site-config.js
CHANGED
|
@@ -1,13 +1,31 @@
|
|
|
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;
|
|
4
5
|
}
|
|
6
|
+
export function resolveGithubRepository(config) {
|
|
7
|
+
if (config.github)
|
|
8
|
+
return { owner: config.github.owner, repo: config.github.repo };
|
|
9
|
+
const [owner, repo] = config.repository.replace("https://github.com/", "").split("/");
|
|
10
|
+
return { owner, repo };
|
|
11
|
+
}
|
|
12
|
+
/** The OIDC `sub` values a GitHub Actions run on `ref` of the site's repository can present. */
|
|
13
|
+
export function githubOidcSubjects(config, ref) {
|
|
14
|
+
const { owner, repo } = resolveGithubRepository(config);
|
|
15
|
+
const subjects = [`repo:${owner}/${repo}:ref:refs/heads/${ref}`];
|
|
16
|
+
const { ownerId, repoId } = config.github ?? {};
|
|
17
|
+
if (ownerId !== undefined && repoId !== undefined) {
|
|
18
|
+
subjects.push(`repo:${owner}@${ownerId}/${repo}@${repoId}:ref:refs/heads/${ref}`);
|
|
19
|
+
}
|
|
20
|
+
return subjects;
|
|
21
|
+
}
|
|
5
22
|
export const DEFAULT_CI_BRANCHES = ["main", "staging"];
|
|
6
23
|
const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
7
24
|
const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
8
25
|
const SECRETS_MANAGER_SECRET_ARN_PATTERN = /^arn:aws:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+$/;
|
|
9
26
|
const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
|
|
10
27
|
const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
|
|
28
|
+
const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
|
|
11
29
|
const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
12
30
|
const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
|
|
13
31
|
const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
|
|
@@ -18,7 +36,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
|
18
36
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
19
37
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
20
38
|
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
21
|
-
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", "
|
|
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"];
|
|
22
40
|
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
23
41
|
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
24
42
|
"GET/v1/identities/me",
|
|
@@ -43,6 +61,12 @@ function originOfRedirectUrl(value) {
|
|
|
43
61
|
return value;
|
|
44
62
|
}
|
|
45
63
|
}
|
|
64
|
+
export function resolveProspectusSessionSealingParameterName(config) {
|
|
65
|
+
return `/papyrus/${config.siteId}/prospectus-session-sealing`;
|
|
66
|
+
}
|
|
67
|
+
export function resolveReaderPollSecretParameterName(config) {
|
|
68
|
+
return `/papyrus/${config.siteId}/reader-poll-secret`;
|
|
69
|
+
}
|
|
46
70
|
export function resolveRevalidateSecretParameterName(config) {
|
|
47
71
|
return `/papyrus/${config.siteId}/revalidate-secret`;
|
|
48
72
|
}
|
|
@@ -168,6 +192,9 @@ export function resolveCmsHostName(config) {
|
|
|
168
192
|
return undefined;
|
|
169
193
|
return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
|
|
170
194
|
}
|
|
195
|
+
export function resolveSiteOrigin(config) {
|
|
196
|
+
return config.cms.siteOrigin;
|
|
197
|
+
}
|
|
171
198
|
export function resolveStagingDomainName(config) {
|
|
172
199
|
if (!isStagingEnabled(config))
|
|
173
200
|
return undefined;
|
|
@@ -199,7 +226,7 @@ function parseStagingPreview(value) {
|
|
|
199
226
|
}
|
|
200
227
|
function parseCms(value) {
|
|
201
228
|
const cmsRecord = requireObject(value, "cms");
|
|
202
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
|
|
229
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "readerRetirementPoll", "deployStackInBuild"], "cms");
|
|
203
230
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
204
231
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
205
232
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -210,6 +237,12 @@ function parseCms(value) {
|
|
|
210
237
|
if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
|
|
211
238
|
fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
|
|
212
239
|
}
|
|
240
|
+
if (cmsRecord.readerRetirementPoll !== undefined && typeof cmsRecord.readerRetirementPoll !== "boolean") {
|
|
241
|
+
fail("cms.readerRetirementPoll", `must be a boolean, got ${JSON.stringify(cmsRecord.readerRetirementPoll)}`);
|
|
242
|
+
}
|
|
243
|
+
if (cmsRecord.readerRetirementPoll === true && cmsRecord.prospectusAccountsApiStageArn === undefined) {
|
|
244
|
+
fail("cms.readerRetirementPoll", "requires cms.prospectusAccountsApiStageArn: the poll calls Prospectus as the site");
|
|
245
|
+
}
|
|
213
246
|
if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
|
|
214
247
|
fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
|
|
215
248
|
}
|
|
@@ -217,6 +250,7 @@ function parseCms(value) {
|
|
|
217
250
|
appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
|
|
218
251
|
domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
|
|
219
252
|
domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
253
|
+
siteOrigin: optionalString(cmsRecord.siteOrigin, "cms.siteOrigin", SITE_ORIGIN_PATTERN, "https origin with no path, query or trailing slash, such as https://shipped.anth.us"),
|
|
220
254
|
staging: cmsRecord.staging,
|
|
221
255
|
cognitoDomainPrefix: requireString(cmsRecord.cognitoDomainPrefix, "cms.cognitoDomainPrefix", COGNITO_DOMAIN_PREFIX_PATTERN, "lowercase letters, digits and hyphens; globally unique per region"),
|
|
222
256
|
applyCognitoDomainPrefix: cmsRecord.applyCognitoDomainPrefix,
|
|
@@ -226,6 +260,7 @@ function parseCms(value) {
|
|
|
226
260
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
227
261
|
redirects: parseRedirects(cmsRecord.redirects),
|
|
228
262
|
deployStackInBuild: cmsRecord.deployStackInBuild,
|
|
263
|
+
readerRetirementPoll: cmsRecord.readerRetirementPoll,
|
|
229
264
|
stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
|
|
230
265
|
prospectusAccountsApiStageArn: optionalString(cmsRecord.prospectusAccountsApiStageArn, "cms.prospectusAccountsApiStageArn", PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN, "arn:aws:execute-api:<region>:<account>:<api id>/api"),
|
|
231
266
|
};
|
|
@@ -235,9 +270,12 @@ function parseCms(value) {
|
|
|
235
270
|
}
|
|
236
271
|
for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
|
|
237
272
|
if (key in cms.environment) {
|
|
238
|
-
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
|
|
273
|
+
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn and the public site origin comes from the site host");
|
|
239
274
|
}
|
|
240
275
|
}
|
|
276
|
+
if (cms.siteOrigin && cms.domainName) {
|
|
277
|
+
fail("cms.siteOrigin", "is for sites whose host is served by their own edge; remove it when cms.domainName is set");
|
|
278
|
+
}
|
|
241
279
|
if (cms.staging === false && cms.stagingDomainName) {
|
|
242
280
|
fail("cms.stagingDomainName", "must not be set when cms.staging is false");
|
|
243
281
|
}
|
|
@@ -300,9 +338,20 @@ function validateOauthRedirectUrls(config) {
|
|
|
300
338
|
}
|
|
301
339
|
}
|
|
302
340
|
}
|
|
341
|
+
function parseAws(value) {
|
|
342
|
+
if (value === undefined)
|
|
343
|
+
return undefined;
|
|
344
|
+
const record = requireObject(value, "aws");
|
|
345
|
+
rejectUnknownKeys(record, ["profile", "accountId", "region"], "aws");
|
|
346
|
+
return {
|
|
347
|
+
profile: optionalString(record.profile, "aws.profile", /^[A-Za-z0-9_.-]+$/, "AWS CLI profile name"),
|
|
348
|
+
accountId: optionalString(record.accountId, "aws.accountId", /^\d{12}$/, "12-digit account id"),
|
|
349
|
+
region: optionalString(record.region, "aws.region", /^[a-z]{2}(-[a-z]+)+-\d$/, "AWS region such as us-east-1"),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
303
352
|
export function parseSiteConfig(raw) {
|
|
304
353
|
const record = requireObject(raw, "site");
|
|
305
|
-
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
|
|
354
|
+
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
|
|
306
355
|
const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
|
|
307
356
|
const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
|
|
308
357
|
const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
|
|
@@ -315,6 +364,7 @@ export function parseSiteConfig(raw) {
|
|
|
315
364
|
}
|
|
316
365
|
const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
|
|
317
366
|
const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
|
|
367
|
+
const aws = parseAws(record.aws);
|
|
318
368
|
const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
|
|
319
369
|
const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
|
|
320
370
|
const cms = record.cms === undefined && frontend === "markus-static" ? undefined : parseCms(record.cms);
|
|
@@ -352,12 +402,15 @@ export function parseSiteConfig(raw) {
|
|
|
352
402
|
if (cms?.stagingPreview !== undefined && cms.staging === false) {
|
|
353
403
|
fail("cms.stagingPreview", "must not be set when cms.staging is false");
|
|
354
404
|
}
|
|
405
|
+
const autoDeploy = record.autoDeploy ?? true;
|
|
406
|
+
if (typeof autoDeploy !== "boolean")
|
|
407
|
+
fail("autoDeploy", "must be a boolean");
|
|
355
408
|
let github;
|
|
356
409
|
if (record.github !== undefined) {
|
|
357
410
|
if (cms === undefined)
|
|
358
411
|
fail("github", "requires cms (the CI role is scoped to the CMS app)");
|
|
359
412
|
const githubRecord = requireObject(record.github, "github");
|
|
360
|
-
rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
|
|
413
|
+
rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra", "ownerId", "repoId"], "github");
|
|
361
414
|
const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
|
|
362
415
|
const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
|
|
363
416
|
if (repository !== `https://github.com/${owner}/${repo}`) {
|
|
@@ -378,7 +431,18 @@ export function parseSiteConfig(raw) {
|
|
|
378
431
|
const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
|
|
379
432
|
if (typeof ciCanDeployInfra !== "boolean")
|
|
380
433
|
fail("github.ciCanDeployInfra", "must be a boolean");
|
|
381
|
-
|
|
434
|
+
const optionalId = (value, name) => {
|
|
435
|
+
if (value === undefined)
|
|
436
|
+
return undefined;
|
|
437
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
|
|
438
|
+
fail(name, "must be a positive integer (GitHub numeric id)");
|
|
439
|
+
return value;
|
|
440
|
+
};
|
|
441
|
+
const ownerId = optionalId(githubRecord.ownerId, "github.ownerId");
|
|
442
|
+
const repoId = optionalId(githubRecord.repoId, "github.repoId");
|
|
443
|
+
if ((ownerId === undefined) !== (repoId === undefined))
|
|
444
|
+
fail("github", "ownerId and repoId must be set together");
|
|
445
|
+
github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
|
|
382
446
|
}
|
|
383
447
|
const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
|
|
384
448
|
if (hasDomain && hostedZoneId === undefined) {
|
|
@@ -395,13 +459,31 @@ export function parseSiteConfig(raw) {
|
|
|
395
459
|
frontend,
|
|
396
460
|
hostedZoneId,
|
|
397
461
|
stackName,
|
|
462
|
+
aws,
|
|
398
463
|
cms,
|
|
464
|
+
email: parseCloudEmail(record.email, cms !== undefined, siteId),
|
|
399
465
|
reader,
|
|
400
466
|
papyrusVersion,
|
|
401
467
|
storagePreviewPrefix,
|
|
468
|
+
autoDeploy: autoDeploy,
|
|
402
469
|
github,
|
|
470
|
+
...(record.knowledgeRun !== undefined ? { knowledgeRun: record.knowledgeRun } : {}),
|
|
403
471
|
};
|
|
404
472
|
if (hasCmsApp(config))
|
|
405
473
|
validateOauthRedirectUrls(config);
|
|
406
474
|
return config;
|
|
407
475
|
}
|
|
476
|
+
/**
|
|
477
|
+
* The CDK environment for a site's app-shell stack, taken only from the site's own `aws` block. There is
|
|
478
|
+
* deliberately no default account: a deploy that guesses an account can land in, or fail against, the wrong
|
|
479
|
+
* one.
|
|
480
|
+
*/
|
|
481
|
+
export function resolveDeployEnvironment(config) {
|
|
482
|
+
const account = config.aws?.accountId;
|
|
483
|
+
const region = config.aws?.region;
|
|
484
|
+
const missing = [account ? "" : "aws.accountId", region ? "" : "aws.region"].filter(Boolean);
|
|
485
|
+
if (!account || !region) {
|
|
486
|
+
throw new Error(`site "${config.siteId}" does not say where it deploys: add ${missing.join(" and ")} to its site.json, e.g. "aws": { "accountId": "<12-digit account>", "region": "us-east-1" }. No account is assumed.`);
|
|
487
|
+
}
|
|
488
|
+
return { account, region };
|
|
489
|
+
}
|