@anthusai/papyrus 1.0.0-next.4 → 1.0.0-next.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/amplify/auth/redirect-urls.js +54 -0
  2. package/amplify/auth/redirect-urls.ts +66 -0
  3. package/amplify/auth/resource.js +20 -5
  4. package/amplify/auth/resource.ts +27 -5
  5. package/amplify/data/resource.js +28 -70
  6. package/amplify/data/resource.ts +29 -70
  7. package/amplify/data/schema.js +33 -58
  8. package/amplify/data/schema.ts +34 -58
  9. package/amplify/functions/category-action/handler.ts +5 -3
  10. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  11. package/amplify/functions/console-chat-responder/resource.js +0 -7
  12. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  13. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  14. package/amplify/functions/content-actions/handler.py +45 -3
  15. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  16. package/amplify/inbound-email/ses-intake.js +86 -0
  17. package/amplify/inbound-email/ses-intake.ts +110 -0
  18. package/amplify/rebuild-trigger-settings.js +22 -0
  19. package/amplify/rebuild-trigger-settings.ts +42 -0
  20. package/amplify/resolver-waves.js +26 -0
  21. package/amplify/resolver-waves.ts +34 -0
  22. package/amplify/site-backend-config.ts +36 -1
  23. package/amplify/site-backend-names.js +103 -0
  24. package/amplify/site-backend-names.ts +166 -0
  25. package/amplify/site-backend.js +93 -170
  26. package/amplify/site-backend.ts +126 -196
  27. package/amplify/storage/resource.js +3 -0
  28. package/amplify/storage/resource.ts +3 -0
  29. package/amplify/storage-backups/construct.js +31 -0
  30. package/amplify/storage-backups/construct.ts +47 -0
  31. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  32. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -0
  33. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  34. package/app/api/media/[...path]/route.ts +5 -16
  35. package/app/api/revalidate/route.ts +8 -7
  36. package/app/articles/[slug]/page.tsx +6 -4
  37. package/app/blog-rhythm.css +278 -0
  38. package/app/globals.css +21 -0
  39. package/app/layout.tsx +16 -0
  40. package/app/newsroom/[section]/[...selection]/page.tsx +6 -5
  41. package/app/newsroom/[section]/page.tsx +4 -3
  42. package/app/newsroom/articles/[id]/page.tsx +15 -0
  43. package/app/newsroom/articles/page.tsx +13 -0
  44. package/app/newsroom/layout.tsx +4 -9
  45. package/app/page.tsx +18 -5
  46. package/app/robots.ts +2 -5
  47. package/bin/papyrus-app.mjs +12 -2
  48. package/bin/papyrus-infra.mjs +29 -3
  49. package/components/amplify-client-provider.tsx +4 -22
  50. package/components/archive-shell.tsx +2 -22
  51. package/components/article-video.tsx +247 -3
  52. package/components/blog-page-background.tsx +23 -6
  53. package/components/markus-ir-preview.tsx +300 -0
  54. package/components/news-desk-taxonomy-client.ts +51 -0
  55. package/components/newspaper.tsx +6 -2
  56. package/components/newsroom-access-gate.tsx +67 -0
  57. package/components/newsroom-article-editor.tsx +741 -0
  58. package/components/newsroom-articles-view.tsx +129 -0
  59. package/components/newsroom-client-shell.tsx +12 -0
  60. package/components/newsroom-ops-overview.tsx +28 -11
  61. package/components/newsroom-ops-shell.tsx +3 -1
  62. package/components/newsroom-references-view.tsx +83 -8
  63. package/components/papyrus-console-shell.tsx +4 -2
  64. package/components/pictogram-figure.tsx +9 -2
  65. package/components/presentation-header.tsx +135 -0
  66. package/components/presentation-rhythm-hrule.tsx +17 -0
  67. package/components/reference-cyclotron-status.tsx +109 -0
  68. package/components/reference-relevance-review.tsx +75 -0
  69. package/components/settings-page.tsx +2 -1
  70. package/components/topic-steering-workspace.tsx +54 -64
  71. package/components/use-blog-header-obstacles.ts +81 -0
  72. package/components/use-brand-fonts-ready.ts +28 -0
  73. package/components/use-rhythm-overlay.ts +29 -0
  74. package/infra/amplify-app-shell.js +212 -50
  75. package/infra/amplify-app-shell.ts +240 -53
  76. package/infra/build-specs.js +14 -5
  77. package/infra/build-specs.ts +17 -7
  78. package/infra/github-oidc-provider.js +24 -0
  79. package/infra/github-oidc-provider.ts +30 -0
  80. package/infra/index.js +1 -0
  81. package/infra/site-config.js +167 -10
  82. package/infra/site-config.ts +196 -14
  83. package/lib/amplify-outputs-path.ts +5 -1
  84. package/lib/blog-feature-solver.ts +459 -0
  85. package/lib/blog-rhythm.ts +134 -0
  86. package/lib/brand-fonts-watch.ts +43 -0
  87. package/lib/brand-fonts.ts +112 -0
  88. package/lib/cached-content-repository.ts +12 -0
  89. package/lib/category-repository.ts +42 -93
  90. package/lib/content-actions-demo.ts +190 -0
  91. package/lib/content-repository.ts +3 -0
  92. package/lib/content-source-context.ts +12 -2
  93. package/lib/content-types.ts +4 -0
  94. package/lib/edition-routes.ts +31 -29
  95. package/lib/graphql-content-repository.ts +61 -9
  96. package/lib/media-route-response.ts +45 -0
  97. package/lib/newspaper-layout.ts +3 -2
  98. package/lib/newsroom-articles.ts +325 -0
  99. package/lib/newsroom-base-path.ts +12 -3
  100. package/lib/newsroom-category-drilldown.ts +3 -2
  101. package/lib/newsroom-demo-articles.ts +90 -0
  102. package/lib/newsroom-forum-routes.ts +6 -4
  103. package/lib/newsroom-index-filters.ts +14 -10
  104. package/lib/newsroom-media-backend.ts +142 -0
  105. package/lib/newsroom-media.ts +182 -0
  106. package/lib/newsroom-nav.ts +3 -0
  107. package/lib/oauth-redirect-priority.ts +40 -0
  108. package/lib/papyrus-web-locations.ts +7 -6
  109. package/lib/presentation-footer.ts +3 -2
  110. package/lib/pretext-layout.ts +12 -3
  111. package/lib/reader-base-path.ts +24 -0
  112. package/lib/reader-static-params.ts +13 -0
  113. package/lib/reference-policy.ts +5 -0
  114. package/lib/relevance-decisions.ts +103 -0
  115. package/lib/relevance-status.ts +63 -0
  116. package/lib/renderer.ts +7 -0
  117. package/lib/revalidate-secret.ts +59 -0
  118. package/lib/robots-policy.ts +14 -0
  119. package/lib/root-route-redirect.ts +16 -0
  120. package/lib/semantic-graph.ts +7 -0
  121. package/lib/site-brand.ts +51 -0
  122. package/lib/staging-gated-path.ts +23 -0
  123. package/lib/staging-preview-object.ts +114 -0
  124. package/lib/video-mode.ts +42 -0
  125. package/lib/video-script.ts +77 -0
  126. package/middleware.ts +23 -18
  127. package/package.json +45 -1
  128. package/renderers/pretext/article-page.tsx +39 -3
  129. package/renderers/pretext/presentation-shared.tsx +113 -0
  130. package/renderers/pretext/presentation-shell.tsx +15 -102
  131. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  132. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  133. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  134. package/routes.manifest.json +45 -2
  135. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  136. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  137. package/scripts/videoml/bundle-common.mjs +129 -0
  138. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  139. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  140. package/scripts/videoml/preview/preview-timing.ts +42 -0
  141. package/scripts/videoml/preview.html +77 -0
  142. package/scripts/videoml/standard-entry.tsx +46 -0
  143. package/src/sync.mjs +30 -6
  144. package/src/with-papyrus.mjs +19 -2
  145. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  146. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  147. 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
- return {
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
- return {"ok": True, "changed": result.changed}
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["info"]["fieldName"]
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
- /** Stable Cognito hosted-UI domain prefix. */
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
+ }