@anthusai/papyrus 1.0.0-next.4 → 1.0.0-next.41
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/redirect-urls.js +54 -0
- package/amplify/auth/redirect-urls.ts +66 -0
- package/amplify/auth/resource.js +20 -5
- package/amplify/auth/resource.ts +27 -5
- package/amplify/data/resource.js +28 -70
- package/amplify/data/resource.ts +29 -70
- package/amplify/data/schema.js +33 -58
- package/amplify/data/schema.ts +34 -58
- package/amplify/functions/category-action/handler.ts +5 -3
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
- package/amplify/functions/console-chat-responder/resource.js +0 -7
- package/amplify/functions/console-chat-responder/resource.ts +0 -8
- package/amplify/functions/console-chat-responder/src/main.rs +0 -9
- package/amplify/functions/content-actions/handler.py +45 -3
- package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
- package/amplify/inbound-email/ses-intake.js +86 -0
- package/amplify/inbound-email/ses-intake.ts +110 -0
- package/amplify/rebuild-trigger-settings.js +22 -0
- package/amplify/rebuild-trigger-settings.ts +42 -0
- package/amplify/resolver-waves.js +26 -0
- package/amplify/resolver-waves.ts +34 -0
- package/amplify/site-backend-config.ts +36 -1
- package/amplify/site-backend-names.js +103 -0
- package/amplify/site-backend-names.ts +166 -0
- package/amplify/site-backend.js +93 -170
- package/amplify/site-backend.ts +126 -196
- package/amplify/storage/resource.js +3 -0
- package/amplify/storage/resource.ts +3 -0
- package/amplify/storage-backups/construct.js +31 -0
- package/amplify/storage-backups/construct.ts +47 -0
- package/app/%5F_preview/[[...path]]/route.ts +8 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -0
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/api/media/[...path]/route.ts +5 -16
- package/app/api/revalidate/route.ts +8 -7
- package/app/articles/[slug]/page.tsx +6 -4
- package/app/blog-rhythm.css +278 -0
- package/app/globals.css +21 -0
- package/app/layout.tsx +16 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +6 -5
- package/app/newsroom/[section]/page.tsx +4 -3
- package/app/newsroom/articles/[id]/page.tsx +15 -0
- package/app/newsroom/articles/page.tsx +13 -0
- package/app/newsroom/layout.tsx +4 -9
- package/app/page.tsx +18 -5
- package/app/robots.ts +2 -5
- package/bin/papyrus-app.mjs +12 -2
- package/bin/papyrus-infra.mjs +29 -3
- package/components/amplify-client-provider.tsx +4 -22
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +247 -3
- package/components/blog-page-background.tsx +23 -6
- package/components/markus-ir-preview.tsx +300 -0
- package/components/news-desk-taxonomy-client.ts +51 -0
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +67 -0
- package/components/newsroom-article-editor.tsx +741 -0
- package/components/newsroom-articles-view.tsx +129 -0
- package/components/newsroom-client-shell.tsx +12 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/newsroom-references-view.tsx +83 -8
- package/components/papyrus-console-shell.tsx +4 -2
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/reference-cyclotron-status.tsx +109 -0
- package/components/reference-relevance-review.tsx +75 -0
- package/components/settings-page.tsx +2 -1
- package/components/topic-steering-workspace.tsx +54 -64
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +28 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/infra/amplify-app-shell.js +228 -50
- package/infra/amplify-app-shell.ts +256 -53
- package/infra/build-specs.js +15 -5
- package/infra/build-specs.ts +18 -7
- package/infra/github-oidc-provider.js +24 -0
- package/infra/github-oidc-provider.ts +30 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +236 -11
- package/infra/site-config.ts +276 -15
- package/lib/amplify-outputs-path.ts +5 -1
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/brand-fonts-watch.ts +43 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/cached-content-repository.ts +12 -0
- package/lib/category-repository.ts +42 -93
- package/lib/content-actions-demo.ts +190 -0
- package/lib/content-repository.ts +3 -0
- package/lib/content-source-context.ts +12 -2
- package/lib/content-types.ts +4 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/graphql-content-repository.ts +61 -9
- package/lib/media-route-response.ts +45 -0
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-articles.ts +325 -0
- package/lib/newsroom-base-path.ts +12 -3
- package/lib/newsroom-category-drilldown.ts +3 -2
- package/lib/newsroom-demo-articles.ts +90 -0
- package/lib/newsroom-forum-routes.ts +6 -4
- package/lib/newsroom-index-filters.ts +14 -10
- package/lib/newsroom-media-backend.ts +142 -0
- package/lib/newsroom-media.ts +182 -0
- package/lib/newsroom-nav.ts +3 -0
- package/lib/oauth-redirect-priority.ts +40 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +12 -3
- package/lib/reader-base-path.ts +24 -0
- package/lib/reader-static-params.ts +13 -0
- package/lib/reference-policy.ts +5 -0
- package/lib/relevance-decisions.ts +103 -0
- package/lib/relevance-status.ts +63 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/robots-policy.ts +14 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/semantic-graph.ts +7 -0
- package/lib/site-brand.ts +51 -0
- package/lib/staging-gated-path.ts +23 -0
- package/lib/staging-preview-object.ts +114 -0
- package/lib/video-mode.ts +42 -0
- package/lib/video-script.ts +77 -0
- package/middleware.ts +23 -18
- package/package.json +45 -1
- package/renderers/pretext/article-page.tsx +39 -3
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +15 -102
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +45 -2
- package/scripts/videoml/build-browser-bundle.mjs +22 -0
- package/scripts/videoml/build-preview-bundle.mjs +50 -0
- package/scripts/videoml/bundle-common.mjs +129 -0
- package/scripts/videoml/preview/preview-entry.tsx +381 -0
- package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
- package/scripts/videoml/preview/preview-timing.ts +42 -0
- package/scripts/videoml/preview.html +77 -0
- package/scripts/videoml/standard-entry.tsx +46 -0
- package/src/sync.mjs +30 -6
- package/src/with-papyrus.mjs +19 -2
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
- package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
6
7
|
from papyrus_content.graphql_authoring import create_authoring_client
|
|
7
8
|
from papyrus_content.markus_renderer.derive import derive_body
|
|
9
|
+
from papyrus_content.rebuild_trigger import (
|
|
10
|
+
DEFAULT_STAGING_BRANCH,
|
|
11
|
+
reader_target_from_environment,
|
|
12
|
+
trigger_pretext_revalidation,
|
|
13
|
+
trigger_rebuild,
|
|
14
|
+
trigger_staging_build,
|
|
15
|
+
)
|
|
8
16
|
from papyrus_content.publishing import (
|
|
9
17
|
ItemFields,
|
|
10
18
|
PublishError,
|
|
@@ -72,25 +80,57 @@ def save_item_draft_action(arguments: dict, actor: str) -> dict:
|
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
|
|
83
|
+
def site_triggers_for(client, item_id: str) -> dict:
|
|
84
|
+
triggers: dict = {}
|
|
85
|
+
reader_app_id, reader_branch = reader_target_from_environment()
|
|
86
|
+
revalidate_base_url = (os.environ.get("PAPYRUS_REVALIDATE_BASE_URL") or "").strip()
|
|
87
|
+
if revalidate_base_url:
|
|
88
|
+
try:
|
|
89
|
+
item = client.get_record("Item", item_id) or {}
|
|
90
|
+
slugs = [item["slug"]] if item.get("slug") else []
|
|
91
|
+
revalidated = trigger_pretext_revalidation(slugs, None, base_url=revalidate_base_url)
|
|
92
|
+
triggers["revalidated"] = revalidated if revalidated is not None else {"ok": False, "reason": "not-configured"}
|
|
93
|
+
except Exception as error: # noqa: BLE001 - a trigger failure never fails the publish
|
|
94
|
+
triggers["revalidated"] = {"ok": False, "error": f"{type(error).__name__}: {error}"}
|
|
95
|
+
if reader_app_id:
|
|
96
|
+
triggers["rebuild"] = trigger_rebuild(reader_app_id=reader_app_id, reader_branch=reader_branch)
|
|
97
|
+
return triggers
|
|
98
|
+
|
|
99
|
+
|
|
75
100
|
def publish_item_action(arguments: dict, actor: str) -> dict:
|
|
76
101
|
client, _claims = create_authoring_client()
|
|
77
102
|
result = publish_item(client, arguments["id"], actor=actor)
|
|
78
|
-
|
|
103
|
+
response = {
|
|
79
104
|
"ok": True,
|
|
80
105
|
"changed": result.changed,
|
|
81
106
|
"publishedId": result.published_item_id,
|
|
82
107
|
"versionNumber": result.version_number,
|
|
83
108
|
}
|
|
109
|
+
if result.changed:
|
|
110
|
+
response.update(site_triggers_for(client, arguments["id"]))
|
|
111
|
+
return response
|
|
84
112
|
|
|
85
113
|
|
|
86
114
|
def unpublish_item_action(arguments: dict, actor: str) -> dict:
|
|
87
115
|
client, _claims = create_authoring_client()
|
|
88
116
|
result = unpublish_item(client, arguments["id"], actor=actor)
|
|
89
|
-
|
|
117
|
+
response = {"ok": True, "changed": result.changed}
|
|
118
|
+
if result.changed:
|
|
119
|
+
response.update(site_triggers_for(client, arguments["id"]))
|
|
120
|
+
return response
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def request_staging_build_action() -> dict:
|
|
124
|
+
cms_app_id = (os.environ.get("PAPYRUS_CMS_AMPLIFY_APP_ID") or "").strip()
|
|
125
|
+
if not cms_app_id:
|
|
126
|
+
return {"ok": False, "errors": [{"code": "staging-build-not-configured", "message": "This site has no staging build configured.", "line": None}]}
|
|
127
|
+
staging_branch = (os.environ.get("PAPYRUS_STAGING_BRANCH") or "").strip() or DEFAULT_STAGING_BRANCH
|
|
128
|
+
build = trigger_staging_build(cms_app_id=cms_app_id, staging_branch=staging_branch)
|
|
129
|
+
return {"ok": True, "build": build}
|
|
90
130
|
|
|
91
131
|
|
|
92
132
|
def handler(event, context):
|
|
93
|
-
field = event["
|
|
133
|
+
field = event["fieldName"]
|
|
94
134
|
arguments = parse_input(event["arguments"]["input"])
|
|
95
135
|
actor = actor_from_event(event)
|
|
96
136
|
try:
|
|
@@ -102,6 +142,8 @@ def handler(event, context):
|
|
|
102
142
|
return publish_item_action(arguments, actor)
|
|
103
143
|
if field == "unpublishItem":
|
|
104
144
|
return unpublish_item_action(arguments, actor)
|
|
145
|
+
if field == "requestStagingBuild":
|
|
146
|
+
return request_staging_build_action()
|
|
105
147
|
except PublishError as error:
|
|
106
148
|
return failure_response(error)
|
|
107
149
|
raise ValueError(f"Unsupported field {field}")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata stored with an editor's reference curation Message. When the
|
|
3
|
+
* relevance cyclotron decided the reference, the review records which
|
|
4
|
+
* decision it answered and whether its explanation may be shared (for the
|
|
5
|
+
* Cyclotron marketing recording). Both are optional.
|
|
6
|
+
*/
|
|
7
|
+
export type ReferenceCurationMetadataInput = {
|
|
8
|
+
action: string
|
|
9
|
+
curationStatus: string
|
|
10
|
+
reasonCode: string | null
|
|
11
|
+
referenceId: string
|
|
12
|
+
referenceLineageId: string
|
|
13
|
+
decisionRelationId?: string | null
|
|
14
|
+
shareable?: boolean | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function referenceCurationMetadata(input: ReferenceCurationMetadataInput): Record<string, unknown> {
|
|
18
|
+
const decisionRelationId = input.decisionRelationId?.trim() || null
|
|
19
|
+
return {
|
|
20
|
+
action: input.action,
|
|
21
|
+
curationStatus: input.curationStatus,
|
|
22
|
+
reasonCode: input.reasonCode,
|
|
23
|
+
curationReasonCode: input.reasonCode,
|
|
24
|
+
referenceId: input.referenceId,
|
|
25
|
+
referenceLineageId: input.referenceLineageId,
|
|
26
|
+
...(decisionRelationId ? {decisionRelationId} : {}),
|
|
27
|
+
...(decisionRelationId ? {shareable: input.shareable === true} : {}),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Duration } from "aws-cdk-lib";
|
|
2
|
+
import * as route53 from "aws-cdk-lib/aws-route53";
|
|
3
|
+
import * as ses from "aws-cdk-lib/aws-ses";
|
|
4
|
+
import * as sesActions from "aws-cdk-lib/aws-ses-actions";
|
|
5
|
+
import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
|
|
6
|
+
/**
|
|
7
|
+
* Adds the SES receipt resources directly to `scope` (no wrapper construct) so
|
|
8
|
+
* the logical ids of the live p.apyr.us stack stay unchanged.
|
|
9
|
+
*/
|
|
10
|
+
export function addInboundEmailSesIntake(scope, props) {
|
|
11
|
+
if (props.domainIdentity) {
|
|
12
|
+
const { domain, hostedZoneId, hostedZoneName, recordName } = props.domainIdentity;
|
|
13
|
+
const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "PapyrusInboundDnsZone", {
|
|
14
|
+
hostedZoneId,
|
|
15
|
+
zoneName: hostedZoneName,
|
|
16
|
+
});
|
|
17
|
+
const verifyDomain = new AwsCustomResource(scope, "VerifyInboundEmailDomain", {
|
|
18
|
+
onCreate: {
|
|
19
|
+
service: "SES",
|
|
20
|
+
action: "verifyDomainIdentity",
|
|
21
|
+
parameters: { Domain: domain },
|
|
22
|
+
physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
|
|
23
|
+
},
|
|
24
|
+
onUpdate: {
|
|
25
|
+
service: "SES",
|
|
26
|
+
action: "verifyDomainIdentity",
|
|
27
|
+
parameters: { Domain: domain },
|
|
28
|
+
physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
|
|
29
|
+
},
|
|
30
|
+
policy: AwsCustomResourcePolicy.fromSdkCalls({
|
|
31
|
+
resources: AwsCustomResourcePolicy.ANY_RESOURCE,
|
|
32
|
+
}),
|
|
33
|
+
timeout: Duration.minutes(2),
|
|
34
|
+
});
|
|
35
|
+
new route53.TxtRecord(scope, "PapyrusInboundEmailDomainVerification", {
|
|
36
|
+
zone,
|
|
37
|
+
recordName: `_amazonses.${recordName}`,
|
|
38
|
+
ttl: Duration.minutes(5),
|
|
39
|
+
values: [verifyDomain.getResponseField("VerificationToken")],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const ruleSet = props.existingRuleSetName
|
|
43
|
+
? ses.ReceiptRuleSet.fromReceiptRuleSetName(scope, "PapyrusInboundEmailRuleSet", props.existingRuleSetName)
|
|
44
|
+
: new ses.ReceiptRuleSet(scope, "PapyrusInboundEmailRuleSet", {
|
|
45
|
+
receiptRuleSetName: props.ruleSetName,
|
|
46
|
+
});
|
|
47
|
+
ruleSet.addRule("PapyrusInboundSubmissions", {
|
|
48
|
+
receiptRuleName: props.ruleName,
|
|
49
|
+
recipients: props.recipients,
|
|
50
|
+
enabled: true,
|
|
51
|
+
scanEnabled: true,
|
|
52
|
+
tlsPolicy: ses.TlsPolicy.REQUIRE,
|
|
53
|
+
actions: [
|
|
54
|
+
new sesActions.S3({
|
|
55
|
+
bucket: props.storageBucket,
|
|
56
|
+
objectKeyPrefix: "inbound-email/",
|
|
57
|
+
}),
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
if (props.activateRuleSet && !props.existingRuleSetName) {
|
|
61
|
+
new AwsCustomResource(scope, "ActivateInboundEmailRuleSet", {
|
|
62
|
+
onCreate: {
|
|
63
|
+
service: "SES",
|
|
64
|
+
action: "setActiveReceiptRuleSet",
|
|
65
|
+
parameters: { RuleSetName: ruleSet.receiptRuleSetName },
|
|
66
|
+
physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
|
|
67
|
+
},
|
|
68
|
+
onUpdate: {
|
|
69
|
+
service: "SES",
|
|
70
|
+
action: "setActiveReceiptRuleSet",
|
|
71
|
+
parameters: { RuleSetName: ruleSet.receiptRuleSetName },
|
|
72
|
+
physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
|
|
73
|
+
},
|
|
74
|
+
onDelete: {
|
|
75
|
+
service: "SES",
|
|
76
|
+
action: "setActiveReceiptRuleSet",
|
|
77
|
+
parameters: { RuleSetName: null },
|
|
78
|
+
},
|
|
79
|
+
policy: AwsCustomResourcePolicy.fromSdkCalls({
|
|
80
|
+
resources: AwsCustomResourcePolicy.ANY_RESOURCE,
|
|
81
|
+
}),
|
|
82
|
+
timeout: Duration.minutes(2),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return { ruleSet };
|
|
86
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Duration } from "aws-cdk-lib";
|
|
2
|
+
import * as route53 from "aws-cdk-lib/aws-route53";
|
|
3
|
+
import * as ses from "aws-cdk-lib/aws-ses";
|
|
4
|
+
import * as sesActions from "aws-cdk-lib/aws-ses-actions";
|
|
5
|
+
import type * as s3 from "aws-cdk-lib/aws-s3";
|
|
6
|
+
import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
|
|
7
|
+
import type { Construct } from "constructs";
|
|
8
|
+
|
|
9
|
+
export type InboundEmailSesIntakeProps = {
|
|
10
|
+
storageBucket: s3.IBucket;
|
|
11
|
+
recipients: string[];
|
|
12
|
+
ruleSetName: string;
|
|
13
|
+
/** Adds the rule to a rule set another stack owns instead of creating one. */
|
|
14
|
+
existingRuleSetName?: string;
|
|
15
|
+
/** `undefined` lets CloudFormation generate the name (the live p.apyr.us rule). */
|
|
16
|
+
ruleName?: string;
|
|
17
|
+
/** Only the legacy p.apyr.us pipeline sets this; a new site never activates a rule set. */
|
|
18
|
+
activateRuleSet: boolean;
|
|
19
|
+
domainIdentity?: {
|
|
20
|
+
domain: string;
|
|
21
|
+
hostedZoneId: string;
|
|
22
|
+
hostedZoneName: string;
|
|
23
|
+
recordName: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Adds the SES receipt resources directly to `scope` (no wrapper construct) so
|
|
29
|
+
* the logical ids of the live p.apyr.us stack stay unchanged.
|
|
30
|
+
*/
|
|
31
|
+
export function addInboundEmailSesIntake(scope: Construct, props: InboundEmailSesIntakeProps) {
|
|
32
|
+
if (props.domainIdentity) {
|
|
33
|
+
const { domain, hostedZoneId, hostedZoneName, recordName } = props.domainIdentity;
|
|
34
|
+
const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "PapyrusInboundDnsZone", {
|
|
35
|
+
hostedZoneId,
|
|
36
|
+
zoneName: hostedZoneName,
|
|
37
|
+
});
|
|
38
|
+
const verifyDomain = new AwsCustomResource(scope, "VerifyInboundEmailDomain", {
|
|
39
|
+
onCreate: {
|
|
40
|
+
service: "SES",
|
|
41
|
+
action: "verifyDomainIdentity",
|
|
42
|
+
parameters: { Domain: domain },
|
|
43
|
+
physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
|
|
44
|
+
},
|
|
45
|
+
onUpdate: {
|
|
46
|
+
service: "SES",
|
|
47
|
+
action: "verifyDomainIdentity",
|
|
48
|
+
parameters: { Domain: domain },
|
|
49
|
+
physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
|
|
50
|
+
},
|
|
51
|
+
policy: AwsCustomResourcePolicy.fromSdkCalls({
|
|
52
|
+
resources: AwsCustomResourcePolicy.ANY_RESOURCE,
|
|
53
|
+
}),
|
|
54
|
+
timeout: Duration.minutes(2),
|
|
55
|
+
});
|
|
56
|
+
new route53.TxtRecord(scope, "PapyrusInboundEmailDomainVerification", {
|
|
57
|
+
zone,
|
|
58
|
+
recordName: `_amazonses.${recordName}`,
|
|
59
|
+
ttl: Duration.minutes(5),
|
|
60
|
+
values: [verifyDomain.getResponseField("VerificationToken")],
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ruleSet = props.existingRuleSetName
|
|
65
|
+
? ses.ReceiptRuleSet.fromReceiptRuleSetName(scope, "PapyrusInboundEmailRuleSet", props.existingRuleSetName)
|
|
66
|
+
: new ses.ReceiptRuleSet(scope, "PapyrusInboundEmailRuleSet", {
|
|
67
|
+
receiptRuleSetName: props.ruleSetName,
|
|
68
|
+
});
|
|
69
|
+
ruleSet.addRule("PapyrusInboundSubmissions", {
|
|
70
|
+
receiptRuleName: props.ruleName,
|
|
71
|
+
recipients: props.recipients,
|
|
72
|
+
enabled: true,
|
|
73
|
+
scanEnabled: true,
|
|
74
|
+
tlsPolicy: ses.TlsPolicy.REQUIRE,
|
|
75
|
+
actions: [
|
|
76
|
+
new sesActions.S3({
|
|
77
|
+
bucket: props.storageBucket,
|
|
78
|
+
objectKeyPrefix: "inbound-email/",
|
|
79
|
+
}),
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (props.activateRuleSet && !props.existingRuleSetName) {
|
|
84
|
+
new AwsCustomResource(scope, "ActivateInboundEmailRuleSet", {
|
|
85
|
+
onCreate: {
|
|
86
|
+
service: "SES",
|
|
87
|
+
action: "setActiveReceiptRuleSet",
|
|
88
|
+
parameters: { RuleSetName: ruleSet.receiptRuleSetName },
|
|
89
|
+
physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
|
|
90
|
+
},
|
|
91
|
+
onUpdate: {
|
|
92
|
+
service: "SES",
|
|
93
|
+
action: "setActiveReceiptRuleSet",
|
|
94
|
+
parameters: { RuleSetName: ruleSet.receiptRuleSetName },
|
|
95
|
+
physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
|
|
96
|
+
},
|
|
97
|
+
onDelete: {
|
|
98
|
+
service: "SES",
|
|
99
|
+
action: "setActiveReceiptRuleSet",
|
|
100
|
+
parameters: { RuleSetName: null },
|
|
101
|
+
},
|
|
102
|
+
policy: AwsCustomResourcePolicy.fromSdkCalls({
|
|
103
|
+
resources: AwsCustomResourcePolicy.ANY_RESOURCE,
|
|
104
|
+
}),
|
|
105
|
+
timeout: Duration.minutes(2),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { ruleSet };
|
|
110
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const REBUILD_TRIGGER_ACTIONS = ["amplify:StartJob", "amplify:ListJobs"];
|
|
2
|
+
function branchJobsArn(input, appId, branch) {
|
|
3
|
+
return `arn:aws:amplify:${input.region}:${input.account}:apps/${appId}/branches/${branch}/jobs/*`;
|
|
4
|
+
}
|
|
5
|
+
export function rebuildTriggerSettings(input) {
|
|
6
|
+
const environment = {};
|
|
7
|
+
const resources = [];
|
|
8
|
+
const readerAppId = (input.reader?.amplifyAppId ?? "").trim();
|
|
9
|
+
if (readerAppId !== "") {
|
|
10
|
+
const readerBranch = (input.reader?.branchName ?? "").trim() || "main";
|
|
11
|
+
environment.PAPYRUS_READER_AMPLIFY_APP_ID = readerAppId;
|
|
12
|
+
environment.PAPYRUS_READER_BRANCH = readerBranch;
|
|
13
|
+
resources.push(branchJobsArn(input, readerAppId, readerBranch));
|
|
14
|
+
}
|
|
15
|
+
const cmsAppId = input.cmsAppId.trim();
|
|
16
|
+
if (input.stagingBuildEnabled && cmsAppId !== "") {
|
|
17
|
+
environment.PAPYRUS_CMS_AMPLIFY_APP_ID = cmsAppId;
|
|
18
|
+
environment.PAPYRUS_STAGING_BRANCH = "staging";
|
|
19
|
+
resources.push(branchJobsArn(input, cmsAppId, "staging"));
|
|
20
|
+
}
|
|
21
|
+
return { environment, actions: REBUILD_TRIGGER_ACTIONS, resources };
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment and IAM policy for the content-actions Lambda's site triggers.
|
|
3
|
+
* Pure (no CDK imports) so the policy can be tested without synthesizing.
|
|
4
|
+
*/
|
|
5
|
+
export type RebuildTriggerInput = {
|
|
6
|
+
reader?: { amplifyAppId: string; branchName?: string };
|
|
7
|
+
stagingBuildEnabled: boolean;
|
|
8
|
+
cmsAppId: string;
|
|
9
|
+
region: string;
|
|
10
|
+
account: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type RebuildTriggerSettings = {
|
|
14
|
+
environment: Record<string, string>;
|
|
15
|
+
actions: string[];
|
|
16
|
+
resources: string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const REBUILD_TRIGGER_ACTIONS = ["amplify:StartJob", "amplify:ListJobs"];
|
|
20
|
+
|
|
21
|
+
function branchJobsArn(input: RebuildTriggerInput, appId: string, branch: string): string {
|
|
22
|
+
return `arn:aws:amplify:${input.region}:${input.account}:apps/${appId}/branches/${branch}/jobs/*`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function rebuildTriggerSettings(input: RebuildTriggerInput): RebuildTriggerSettings {
|
|
26
|
+
const environment: Record<string, string> = {};
|
|
27
|
+
const resources: string[] = [];
|
|
28
|
+
const readerAppId = (input.reader?.amplifyAppId ?? "").trim();
|
|
29
|
+
if (readerAppId !== "") {
|
|
30
|
+
const readerBranch = (input.reader?.branchName ?? "").trim() || "main";
|
|
31
|
+
environment.PAPYRUS_READER_AMPLIFY_APP_ID = readerAppId;
|
|
32
|
+
environment.PAPYRUS_READER_BRANCH = readerBranch;
|
|
33
|
+
resources.push(branchJobsArn(input, readerAppId, readerBranch));
|
|
34
|
+
}
|
|
35
|
+
const cmsAppId = input.cmsAppId.trim();
|
|
36
|
+
if (input.stagingBuildEnabled && cmsAppId !== "") {
|
|
37
|
+
environment.PAPYRUS_CMS_AMPLIFY_APP_ID = cmsAppId;
|
|
38
|
+
environment.PAPYRUS_STAGING_BRANCH = "staging";
|
|
39
|
+
resources.push(branchJobsArn(input, cmsAppId, "staging"));
|
|
40
|
+
}
|
|
41
|
+
return { environment, actions: REBUILD_TRIGGER_ACTIONS, resources };
|
|
42
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NestedStack } from "aws-cdk-lib";
|
|
2
|
+
export const DEFAULT_RESOLVER_WAVE_SIZE = 12;
|
|
3
|
+
export function planResolverWaves(items, waveSize) {
|
|
4
|
+
if (!Number.isInteger(waveSize) || waveSize < 1) {
|
|
5
|
+
return [];
|
|
6
|
+
}
|
|
7
|
+
const waves = [];
|
|
8
|
+
for (let start = 0; start < items.length; start += waveSize) {
|
|
9
|
+
waves.push(items.slice(start, start + waveSize));
|
|
10
|
+
}
|
|
11
|
+
return waves;
|
|
12
|
+
}
|
|
13
|
+
export function orderModelStacksInWaves(graphqlApi, modelNames, waveSize) {
|
|
14
|
+
const modelStacks = modelNames
|
|
15
|
+
.map((modelName) => graphqlApi.node.tryFindChild(modelName))
|
|
16
|
+
.filter((child) => child instanceof NestedStack);
|
|
17
|
+
const waves = planResolverWaves(modelStacks, waveSize);
|
|
18
|
+
for (let waveIndex = 1; waveIndex < waves.length; waveIndex += 1) {
|
|
19
|
+
for (const stack of waves[waveIndex]) {
|
|
20
|
+
for (const previousStack of waves[waveIndex - 1]) {
|
|
21
|
+
stack.addDependency(previousStack);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return waves.length;
|
|
26
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { NestedStack } from "aws-cdk-lib";
|
|
2
|
+
import type { IConstruct } from "constructs";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_RESOLVER_WAVE_SIZE = 12;
|
|
5
|
+
|
|
6
|
+
export function planResolverWaves<T>(items: T[], waveSize: number): T[][] {
|
|
7
|
+
if (!Number.isInteger(waveSize) || waveSize < 1) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
const waves: T[][] = [];
|
|
11
|
+
for (let start = 0; start < items.length; start += waveSize) {
|
|
12
|
+
waves.push(items.slice(start, start + waveSize));
|
|
13
|
+
}
|
|
14
|
+
return waves;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function orderModelStacksInWaves(
|
|
18
|
+
graphqlApi: IConstruct,
|
|
19
|
+
modelNames: string[],
|
|
20
|
+
waveSize: number,
|
|
21
|
+
): number {
|
|
22
|
+
const modelStacks = modelNames
|
|
23
|
+
.map((modelName) => graphqlApi.node.tryFindChild(modelName))
|
|
24
|
+
.filter((child): child is NestedStack => child instanceof NestedStack);
|
|
25
|
+
const waves = planResolverWaves(modelStacks, waveSize);
|
|
26
|
+
for (let waveIndex = 1; waveIndex < waves.length; waveIndex += 1) {
|
|
27
|
+
for (const stack of waves[waveIndex]) {
|
|
28
|
+
for (const previousStack of waves[waveIndex - 1]) {
|
|
29
|
+
stack.addDependency(previousStack);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return waves.length;
|
|
34
|
+
}
|
|
@@ -7,8 +7,19 @@ export type PapyrusAuthConfig = {
|
|
|
7
7
|
/** OAuth callback/logout URLs for Google sign-in (ignored when Google is disabled). */
|
|
8
8
|
redirectUrls?: string[];
|
|
9
9
|
disableGoogleOAuth?: boolean;
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Public newsroom path prefix for sign-in return URLs. Default `/newsroom`;
|
|
12
|
+
* `""` on a CMS-only host where the newsroom is the root. Filled from the
|
|
13
|
+
* active brand's `newsroomBasePath` by `defineSiteBackend` when unset.
|
|
14
|
+
*/
|
|
15
|
+
newsroomBasePath?: string;
|
|
16
|
+
/** Desired Cognito hosted-UI domain prefix. Only applied when `applyCognitoDomainPrefix` is true. */
|
|
11
17
|
cognitoDomainPrefix?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Opt in to the prefix. Off by default because changing a live pool's
|
|
20
|
+
* domain replaces it and changes the Google redirect URI.
|
|
21
|
+
*/
|
|
22
|
+
applyCognitoDomainPrefix?: boolean;
|
|
12
23
|
};
|
|
13
24
|
|
|
14
25
|
export type PapyrusSiteBackendConfig = {
|
|
@@ -21,6 +32,30 @@ export type PapyrusSiteBackendConfig = {
|
|
|
21
32
|
*/
|
|
22
33
|
productionAppId?: string;
|
|
23
34
|
auth?: PapyrusAuthConfig;
|
|
35
|
+
/**
|
|
36
|
+
* Maximum number of data model nested stacks CloudFormation creates at once.
|
|
37
|
+
* Stacks are chained in waves of this size so a new AWS account is not hit
|
|
38
|
+
* with every AppSync CreateResolver call in parallel (429). Only adds
|
|
39
|
+
* DependsOn ordering; no resource changes. Default 12; 0 turns it off.
|
|
40
|
+
*/
|
|
41
|
+
resolverWaveSize?: number;
|
|
42
|
+
/** Static reader app (Markus-static sites) that a publish rebuilds through IAM `amplify:StartJob`. */
|
|
43
|
+
reader?: { amplifyAppId: string; branchName?: string };
|
|
44
|
+
/** Lets the newsroom start the `staging` branch build of this CMS app. */
|
|
45
|
+
stagingBuild?: { enabled: boolean };
|
|
46
|
+
/** Base URL of a Pretext reader whose `/api/revalidate` a publish calls (authenticated by the SSM secret named in branch variable `PAPYRUS_REVALIDATE_SECRET_PARAMETER`). */
|
|
47
|
+
revalidateBaseUrl?: string;
|
|
48
|
+
/**
|
|
49
|
+
* SES receipt options for a site that is not the legacy p.apyr.us backend.
|
|
50
|
+
* By default the site gets its own brand-named rule set that is never
|
|
51
|
+
* activated by CloudFormation (SES allows one active rule set per region).
|
|
52
|
+
*/
|
|
53
|
+
inboundEmailSes?: {
|
|
54
|
+
/** Add this site's rule to a rule set another stack owns instead of creating one. */
|
|
55
|
+
existingReceiptRuleSetName?: string;
|
|
56
|
+
/** Let this stack verify the SES domain identity and write its TXT record. Default false: verify once by hand. */
|
|
57
|
+
manageDomainIdentity?: boolean;
|
|
58
|
+
};
|
|
24
59
|
features?: {
|
|
25
60
|
consoleResponder?: boolean;
|
|
26
61
|
inboundEmail?: boolean;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Token } from "aws-cdk-lib";
|
|
3
|
+
export function sanitizeAwsName(value, maxLength = 50) {
|
|
4
|
+
return value
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
7
|
+
.replace(/-+/g, "-")
|
|
8
|
+
.replace(/^-|-$/g, "")
|
|
9
|
+
.slice(0, maxLength);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Account-global names are immutable in AWS, so they must be built from literal
|
|
13
|
+
* synth-time inputs only. A CDK token (or text that came from one, such as
|
|
14
|
+
* `token-token-17565`) makes the name change on every synth and fail with
|
|
15
|
+
* AlreadyExists, so this throws instead.
|
|
16
|
+
*/
|
|
17
|
+
export function assertLiteralResourceName(label, name) {
|
|
18
|
+
if (Token.isUnresolved(name) || /token|\$\{/i.test(name)) {
|
|
19
|
+
throw new Error(`${label} "${name}" is derived from an unresolved CDK token. Build account-global names from literal site config values only.`);
|
|
20
|
+
}
|
|
21
|
+
return name;
|
|
22
|
+
}
|
|
23
|
+
export function readBooleanEnvFlag(environment, name, defaultValue) {
|
|
24
|
+
const raw = (environment[name] ?? "").trim().toLowerCase();
|
|
25
|
+
if (["1", "true", "yes", "on"].includes(raw))
|
|
26
|
+
return true;
|
|
27
|
+
if (["0", "false", "no", "off"].includes(raw))
|
|
28
|
+
return false;
|
|
29
|
+
return defaultValue;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* True only for the Amplify app that owns the original p.apyr.us backend. That
|
|
33
|
+
* backend keeps its historical account-global names so its live resources are
|
|
34
|
+
* never replaced. Every other site gets brand-scoped names.
|
|
35
|
+
*/
|
|
36
|
+
export function isLegacyProductionApp(identity) {
|
|
37
|
+
return identity.productionAppId !== "" && identity.amplifyAppId === identity.productionAppId;
|
|
38
|
+
}
|
|
39
|
+
export function isLegacyProductionPipeline(identity) {
|
|
40
|
+
return isLegacyProductionApp(identity) && identity.amplifyBranch === "main";
|
|
41
|
+
}
|
|
42
|
+
export function resolveSiteBackendFeatureFlags(features, environment, identity) {
|
|
43
|
+
const legacyPipeline = isLegacyProductionPipeline(identity);
|
|
44
|
+
return {
|
|
45
|
+
consoleResponder: features?.consoleResponder ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_CONSOLE_RESPONDER", true),
|
|
46
|
+
inboundEmail: features?.inboundEmail ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_INBOUND_EMAIL", legacyPipeline),
|
|
47
|
+
slack: features?.slack ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_SLACK", false),
|
|
48
|
+
storageBackups: features?.storageBackups ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_STORAGE_BACKUPS", legacyPipeline),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function deriveKnowledgeVectorIndexName(identity) {
|
|
52
|
+
const name = isLegacyProductionApp(identity)
|
|
53
|
+
? "papyrus-knowledge"
|
|
54
|
+
: `papyrus-knowledge-${sanitizeAwsName(identity.brandId, 40)}`;
|
|
55
|
+
return assertLiteralResourceName("Knowledge vector index name", name);
|
|
56
|
+
}
|
|
57
|
+
export function deriveStorageBackupVaultName(identity, explicitName) {
|
|
58
|
+
const explicit = (explicitName ?? "").trim();
|
|
59
|
+
if (explicit !== "")
|
|
60
|
+
return assertLiteralResourceName("Backup vault name", sanitizeAwsName(explicit));
|
|
61
|
+
if (isLegacyProductionPipeline(identity)) {
|
|
62
|
+
return assertLiteralResourceName("Backup vault name", sanitizeAwsName(`papyrus-${identity.productionAppId}-main-media-backup-vault`));
|
|
63
|
+
}
|
|
64
|
+
const literalSiteKey = [identity.brandId, identity.amplifyAppId, identity.amplifyBranch].join("|");
|
|
65
|
+
const siteHash = createHash("sha256").update(literalSiteKey).digest("hex").slice(0, 8);
|
|
66
|
+
return assertLiteralResourceName("Backup vault name", `papyrus-${sanitizeAwsName(identity.brandId, 20)}-${siteHash}-media-vault`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The `papyrus-site-inbound-` prefix keeps a brand named `p-apyr-us` from
|
|
70
|
+
* colliding with the legacy `papyrus-inbound-p-apyr-us` set.
|
|
71
|
+
* SES allows one ACTIVE receipt rule set per region. A non-legacy site creates
|
|
72
|
+
* its own brand-named rule set and never activates it from CloudFormation.
|
|
73
|
+
*/
|
|
74
|
+
export function deriveReceiptRuleSetName(identity, inboundEmailDomain) {
|
|
75
|
+
const name = isLegacyProductionApp(identity)
|
|
76
|
+
? `papyrus-inbound-${inboundEmailDomain.replace(/\./g, "-")}`
|
|
77
|
+
: `papyrus-site-inbound-${sanitizeAwsName(identity.brandId, 40)}`;
|
|
78
|
+
return assertLiteralResourceName("Receipt rule set name", name);
|
|
79
|
+
}
|
|
80
|
+
/** `undefined` keeps the legacy auto-generated rule name of the live p.apyr.us rule. */
|
|
81
|
+
export function deriveReceiptRuleName(identity) {
|
|
82
|
+
if (isLegacyProductionApp(identity))
|
|
83
|
+
return undefined;
|
|
84
|
+
return assertLiteralResourceName("Receipt rule name", `${sanitizeAwsName(identity.brandId, 40)}-inbound-submissions`);
|
|
85
|
+
}
|
|
86
|
+
export function planInboundEmailSes(identity, inboundEmailEnabled, options) {
|
|
87
|
+
const legacyPipeline = isLegacyProductionPipeline(identity);
|
|
88
|
+
const deployedMainBranch = identity.amplifyAppId !== "" && identity.amplifyBranch === "main";
|
|
89
|
+
const createReceiptRules = inboundEmailEnabled && (legacyPipeline || deployedMainBranch);
|
|
90
|
+
return {
|
|
91
|
+
createReceiptRules,
|
|
92
|
+
activateReceiptRuleSet: createReceiptRules && legacyPipeline,
|
|
93
|
+
manageDomainIdentity: createReceiptRules && (legacyPipeline || options?.manageDomainIdentity === true),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function deriveSiteGlobalResourceNames(identity, inboundEmailDomain) {
|
|
97
|
+
return {
|
|
98
|
+
knowledgeVectorIndexName: deriveKnowledgeVectorIndexName(identity),
|
|
99
|
+
storageBackupVaultName: deriveStorageBackupVaultName(identity),
|
|
100
|
+
receiptRuleSetName: deriveReceiptRuleSetName(identity, inboundEmailDomain),
|
|
101
|
+
receiptRuleName: deriveReceiptRuleName(identity),
|
|
102
|
+
};
|
|
103
|
+
}
|