@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
@@ -0,0 +1,166 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Token } from "aws-cdk-lib";
3
+
4
+ export type SiteBackendFeatureFlags = {
5
+ consoleResponder: boolean;
6
+ inboundEmail: boolean;
7
+ slack: boolean;
8
+ storageBackups: boolean;
9
+ };
10
+
11
+ export type SiteBackendFeatureInput = Partial<SiteBackendFeatureFlags> | undefined;
12
+
13
+ export function sanitizeAwsName(value: string, maxLength = 50): string {
14
+ return value
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9-]/g, "-")
17
+ .replace(/-+/g, "-")
18
+ .replace(/^-|-$/g, "")
19
+ .slice(0, maxLength);
20
+ }
21
+
22
+ /**
23
+ * Account-global names are immutable in AWS, so they must be built from literal
24
+ * synth-time inputs only. A CDK token (or text that came from one, such as
25
+ * `token-token-17565`) makes the name change on every synth and fail with
26
+ * AlreadyExists, so this throws instead.
27
+ */
28
+ export function assertLiteralResourceName(label: string, name: string): string {
29
+ if (Token.isUnresolved(name) || /token|\$\{/i.test(name)) {
30
+ throw new Error(
31
+ `${label} "${name}" is derived from an unresolved CDK token. Build account-global names from literal site config values only.`,
32
+ );
33
+ }
34
+ return name;
35
+ }
36
+
37
+ export function readBooleanEnvFlag(
38
+ environment: Record<string, string | undefined>,
39
+ name: string,
40
+ defaultValue: boolean,
41
+ ): boolean {
42
+ const raw = (environment[name] ?? "").trim().toLowerCase();
43
+ if (["1", "true", "yes", "on"].includes(raw)) return true;
44
+ if (["0", "false", "no", "off"].includes(raw)) return false;
45
+ return defaultValue;
46
+ }
47
+
48
+ export type SiteBackendIdentity = {
49
+ brandId: string;
50
+ amplifyAppId: string;
51
+ amplifyBranch: string;
52
+ productionAppId: string;
53
+ };
54
+
55
+ /**
56
+ * True only for the Amplify app that owns the original p.apyr.us backend. That
57
+ * backend keeps its historical account-global names so its live resources are
58
+ * never replaced. Every other site gets brand-scoped names.
59
+ */
60
+ export function isLegacyProductionApp(identity: SiteBackendIdentity): boolean {
61
+ return identity.productionAppId !== "" && identity.amplifyAppId === identity.productionAppId;
62
+ }
63
+
64
+ export function isLegacyProductionPipeline(identity: SiteBackendIdentity): boolean {
65
+ return isLegacyProductionApp(identity) && identity.amplifyBranch === "main";
66
+ }
67
+
68
+ export function resolveSiteBackendFeatureFlags(
69
+ features: SiteBackendFeatureInput,
70
+ environment: Record<string, string | undefined>,
71
+ identity: SiteBackendIdentity,
72
+ ): SiteBackendFeatureFlags {
73
+ const legacyPipeline = isLegacyProductionPipeline(identity);
74
+ return {
75
+ consoleResponder:
76
+ features?.consoleResponder ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_CONSOLE_RESPONDER", true),
77
+ inboundEmail:
78
+ features?.inboundEmail ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_INBOUND_EMAIL", legacyPipeline),
79
+ slack: features?.slack ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_SLACK", false),
80
+ storageBackups:
81
+ features?.storageBackups ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_STORAGE_BACKUPS", legacyPipeline),
82
+ };
83
+ }
84
+
85
+ export function deriveKnowledgeVectorIndexName(identity: SiteBackendIdentity): string {
86
+ const name = isLegacyProductionApp(identity)
87
+ ? "papyrus-knowledge"
88
+ : `papyrus-knowledge-${sanitizeAwsName(identity.brandId, 40)}`;
89
+ return assertLiteralResourceName("Knowledge vector index name", name);
90
+ }
91
+
92
+ export function deriveStorageBackupVaultName(identity: SiteBackendIdentity, explicitName?: string): string {
93
+ const explicit = (explicitName ?? "").trim();
94
+ if (explicit !== "") return assertLiteralResourceName("Backup vault name", sanitizeAwsName(explicit));
95
+ if (isLegacyProductionPipeline(identity)) {
96
+ return assertLiteralResourceName(
97
+ "Backup vault name",
98
+ sanitizeAwsName(`papyrus-${identity.productionAppId}-main-media-backup-vault`),
99
+ );
100
+ }
101
+ const literalSiteKey = [identity.brandId, identity.amplifyAppId, identity.amplifyBranch].join("|");
102
+ const siteHash = createHash("sha256").update(literalSiteKey).digest("hex").slice(0, 8);
103
+ return assertLiteralResourceName(
104
+ "Backup vault name",
105
+ `papyrus-${sanitizeAwsName(identity.brandId, 20)}-${siteHash}-media-vault`,
106
+ );
107
+ }
108
+
109
+ /**
110
+ * The `papyrus-site-inbound-` prefix keeps a brand named `p-apyr-us` from
111
+ * colliding with the legacy `papyrus-inbound-p-apyr-us` set.
112
+ * SES allows one ACTIVE receipt rule set per region. A non-legacy site creates
113
+ * its own brand-named rule set and never activates it from CloudFormation.
114
+ */
115
+ export function deriveReceiptRuleSetName(identity: SiteBackendIdentity, inboundEmailDomain: string): string {
116
+ const name = isLegacyProductionApp(identity)
117
+ ? `papyrus-inbound-${inboundEmailDomain.replace(/\./g, "-")}`
118
+ : `papyrus-site-inbound-${sanitizeAwsName(identity.brandId, 40)}`;
119
+ return assertLiteralResourceName("Receipt rule set name", name);
120
+ }
121
+
122
+ /** `undefined` keeps the legacy auto-generated rule name of the live p.apyr.us rule. */
123
+ export function deriveReceiptRuleName(identity: SiteBackendIdentity): string | undefined {
124
+ if (isLegacyProductionApp(identity)) return undefined;
125
+ return assertLiteralResourceName("Receipt rule name", `${sanitizeAwsName(identity.brandId, 40)}-inbound-submissions`);
126
+ }
127
+
128
+ export type InboundEmailSesPlan = {
129
+ createReceiptRules: boolean;
130
+ activateReceiptRuleSet: boolean;
131
+ manageDomainIdentity: boolean;
132
+ };
133
+
134
+ export function planInboundEmailSes(
135
+ identity: SiteBackendIdentity,
136
+ inboundEmailEnabled: boolean,
137
+ options: { manageDomainIdentity?: boolean } | undefined,
138
+ ): InboundEmailSesPlan {
139
+ const legacyPipeline = isLegacyProductionPipeline(identity);
140
+ const deployedMainBranch = identity.amplifyAppId !== "" && identity.amplifyBranch === "main";
141
+ const createReceiptRules = inboundEmailEnabled && (legacyPipeline || deployedMainBranch);
142
+ return {
143
+ createReceiptRules,
144
+ activateReceiptRuleSet: createReceiptRules && legacyPipeline,
145
+ manageDomainIdentity: createReceiptRules && (legacyPipeline || options?.manageDomainIdentity === true),
146
+ };
147
+ }
148
+
149
+ export type SiteGlobalResourceNames = {
150
+ knowledgeVectorIndexName: string;
151
+ storageBackupVaultName: string;
152
+ receiptRuleSetName: string;
153
+ receiptRuleName: string | undefined;
154
+ };
155
+
156
+ export function deriveSiteGlobalResourceNames(
157
+ identity: SiteBackendIdentity,
158
+ inboundEmailDomain: string,
159
+ ): SiteGlobalResourceNames {
160
+ return {
161
+ knowledgeVectorIndexName: deriveKnowledgeVectorIndexName(identity),
162
+ storageBackupVaultName: deriveStorageBackupVaultName(identity),
163
+ receiptRuleSetName: deriveReceiptRuleSetName(identity, inboundEmailDomain),
164
+ receiptRuleName: deriveReceiptRuleName(identity),
165
+ };
166
+ }
@@ -1,23 +1,18 @@
1
1
  import { defineBackend, secret } from "@aws-amplify/backend";
2
- import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
3
- import * as backup from "aws-cdk-lib/aws-backup";
4
- import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
2
+ import { Stack } from "aws-cdk-lib";
5
3
  import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
6
- import * as events from "aws-cdk-lib/aws-events";
7
- import * as route53 from "aws-cdk-lib/aws-route53";
8
- import * as ses from "aws-cdk-lib/aws-ses";
9
- import * as sesActions from "aws-cdk-lib/aws-ses-actions";
10
4
  import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
11
5
  import { CfnEventSourceMapping, FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
12
6
  import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aws-s3vectors";
13
7
  import { dirname, resolve } from "node:path";
14
8
  import { fileURLToPath } from "node:url";
15
- import { authConfigFromEnv, defineSiteAuth } from "./auth/resource.js";
9
+ import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves.js";
10
+ import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
11
+ import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource.js";
16
12
  import { data } from "./data/resource.js";
17
13
  import { assignmentAction } from "./functions/assignment-action/resource.js";
18
14
  import { categoryAction } from "./functions/category-action/resource.js";
19
15
  import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource.js";
20
- import { graphqlJwtAuthorizer } from "./functions/graphql-jwt-authorizer/resource.js";
21
16
  import { contentActions } from "./functions/content-actions/resource.js";
22
17
  import { knowledgeQuery } from "./functions/knowledge-query/resource.js";
23
18
  import { manageUserRole } from "./functions/manage-user-role/resource.js";
@@ -29,7 +24,10 @@ import { emailSubmissionProcessor } from "./functions/email-submission-processor
29
24
  import { sesInboundReceive } from "./functions/ses-inbound-receive/resource.js";
30
25
  import { slackDelivery } from "./functions/slack-delivery/resource.js";
31
26
  import { slackEvents } from "./functions/slack-events/resource.js";
27
+ import { addInboundEmailSesIntake } from "./inbound-email/ses-intake.js";
32
28
  import { InboundEmailStack } from "./inbound-email/stack.js";
29
+ import { deriveKnowledgeVectorIndexName, deriveReceiptRuleName, deriveReceiptRuleSetName, deriveStorageBackupVaultName, isLegacyProductionPipeline, planInboundEmailSes, resolveSiteBackendFeatureFlags, } from "./site-backend-names.js";
30
+ import { addStorageBackups } from "./storage-backups/construct.js";
33
31
  import { storage } from "./storage/resource.js";
34
32
  export function defineSiteBackend(papyrusSite) {
35
33
  // `papyrus.config.ts`'s default export. Backend options live under `backend`;
@@ -39,50 +37,36 @@ export function defineSiteBackend(papyrusSite) {
39
37
  const knowledgeEmbeddingModel = "text-embedding-3-small";
40
38
  const amplifyBranch = (process.env.AWS_BRANCH ?? "").trim();
41
39
  const amplifyAppId = (process.env.AWS_APP_ID ?? "").trim();
42
- // Amplify app id whose `main` branch is this site's production pipeline (owns
43
- // account-global resources: SES rule set, backup vault, legacy S3 Vectors name).
40
+ // Amplify app id whose `main` branch is the original p.apyr.us backend; only
41
+ // that app keeps the historical account-global names (see site-backend-names.ts).
44
42
  const productionAppId = (site.productionAppId ?? "").trim();
45
- const isAmplifyProductionPipeline = amplifyBranch === "main" && productionAppId !== "" && amplifyAppId === productionAppId;
46
- function readEnvFlag(name, defaultValue) {
47
- const raw = (process.env[name] ?? "").trim().toLowerCase();
48
- if (["1", "true", "yes", "on"].includes(raw))
49
- return true;
50
- if (["0", "false", "no", "off"].includes(raw))
51
- return false;
52
- return defaultValue;
53
- }
54
- function sanitizeAwsName(value, maxLength = 50) {
55
- return value
56
- .toLowerCase()
57
- .replace(/[^a-z0-9-]/g, "-")
58
- .replace(/-+/g, "-")
59
- .replace(/^-|-$/g, "")
60
- .slice(0, maxLength);
61
- }
62
- // S3 Vectors index names are account-global. Keep the original name for the
63
- // p.apyr.us production app so its existing index + data are not orphaned;
64
- // namespace every other app's index by site brand so a second CMS app can
65
- // deploy into the same account without colliding.
66
- const papyrusSiteBrand = (site.brandId
67
- ?? process.env.PAPYRUS_SITE_BRAND
68
- ?? process.env.NEXT_PUBLIC_PAPYRUS_SITE_BRAND
69
- ?? "papyrus").trim().toLowerCase();
70
- const knowledgeVectorIndexName = productionAppId !== "" && amplifyAppId === productionAppId
71
- ? "papyrus-knowledge"
72
- : `papyrus-knowledge-${sanitizeAwsName(papyrusSiteBrand, 40)}`;
73
- // SES active receipt rule sets and shared backup vault names are account-global.
74
- // Keep them on the production pipeline only unless explicitly opted in.
75
- // A feature flag in the site config wins over the environment.
76
- const enableInboundEmail = site.features?.inboundEmail ?? readEnvFlag("PAPYRUS_ENABLE_INBOUND_EMAIL", isAmplifyProductionPipeline);
77
- const enableSlackAgent = site.features?.slack ?? readEnvFlag("PAPYRUS_ENABLE_SLACK", false);
78
- const enableStorageBackups = site.features?.storageBackups ?? readEnvFlag("PAPYRUS_ENABLE_STORAGE_BACKUPS", isAmplifyProductionPipeline);
43
+ const siteBackendIdentity = {
44
+ brandId: (site.brandId
45
+ ?? process.env.PAPYRUS_SITE_BRAND
46
+ ?? process.env.NEXT_PUBLIC_PAPYRUS_SITE_BRAND
47
+ ?? "papyrus").trim().toLowerCase(),
48
+ amplifyAppId,
49
+ amplifyBranch,
50
+ productionAppId,
51
+ };
52
+ const isAmplifyProductionPipeline = isLegacyProductionPipeline(siteBackendIdentity);
53
+ const knowledgeVectorIndexName = deriveKnowledgeVectorIndexName(siteBackendIdentity);
54
+ const featureFlags = resolveSiteBackendFeatureFlags(site.features, process.env, siteBackendIdentity);
55
+ const enableInboundEmail = featureFlags.inboundEmail;
56
+ const enableSlackAgent = featureFlags.slack;
57
+ const enableStorageBackups = featureFlags.storageBackups;
58
+ const baseAuthConfig = site.auth ?? authConfigFromEnv();
59
+ const activeBrand = (papyrusSite.brands ?? []).find((brand) => brand.id.toLowerCase() === siteBackendIdentity.brandId);
60
+ const authConfig = {
61
+ ...baseAuthConfig,
62
+ newsroomBasePath: baseAuthConfig.newsroomBasePath ?? activeBrand?.newsroomBasePath,
63
+ };
79
64
  const backend = defineBackend({
80
65
  assignmentAction,
81
- auth: defineSiteAuth(site.auth ?? authConfigFromEnv()),
66
+ auth: defineSiteAuth(authConfig),
82
67
  categoryAction,
83
68
  contentActions,
84
69
  data,
85
- graphqlJwtAuthorizer,
86
70
  knowledgeQuery,
87
71
  manageUserRole,
88
72
  modelAttachmentUpload,
@@ -95,10 +79,17 @@ export function defineSiteBackend(papyrusSite) {
95
79
  slackDelivery,
96
80
  storage,
97
81
  });
82
+ orderModelStacksInWaves(backend.data.resources.graphqlApi.node.scope, Object.keys(backend.data.resources.tables), site.resolverWaveSize ?? DEFAULT_RESOLVER_WAVE_SIZE);
83
+ const requestedCognitoDomainPrefix = (authConfig.cognitoDomainPrefix ?? "").trim();
84
+ if (authConfig.applyCognitoDomainPrefix === true
85
+ && requestedCognitoDomainPrefix !== ""
86
+ && authConfig.disableGoogleOAuth !== true) {
87
+ applyCognitoDomainPrefix(backend.auth.stack, requestedCognitoDomainPrefix);
88
+ }
98
89
  const amplifyBackendDir = dirname(fileURLToPath(import.meta.url));
99
90
  // Repo root in a Papyrus checkout; package root when installed from npm.
100
91
  const projectRoot = resolve(amplifyBackendDir, "..");
101
- const enableConsoleResponder = site.features?.consoleResponder ?? readEnvFlag("PAPYRUS_ENABLE_CONSOLE_RESPONDER", true);
92
+ const enableConsoleResponder = featureFlags.consoleResponder;
102
93
  if (enableConsoleResponder
103
94
  && !process.env.PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI?.trim()
104
95
  && !process.env.PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD?.trim()
@@ -138,36 +129,18 @@ export function defineSiteBackend(papyrusSite) {
138
129
  const messageThreadTable = backend.data.resources.tables.MessageThread;
139
130
  const dataStack = Stack.of(messageTable);
140
131
  const graphqlEndpoint = backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl;
141
- const jwtAuthorizerCfn = backend.graphqlJwtAuthorizer.resources.lambda.node.defaultChild;
142
- const jwtAuthorizerEnvironment = jwtAuthorizerCfn?.environment;
143
- const jwtAuthorizerVariables = jwtAuthorizerEnvironment
144
- && typeof jwtAuthorizerEnvironment === "object"
145
- && "variables" in jwtAuthorizerEnvironment
146
- ? jwtAuthorizerEnvironment.variables
147
- : undefined;
148
- const jwtSsmEnvConfig = process.env.AMPLIFY_SSM_ENV_CONFIG?.trim()
149
- || jwtAuthorizerVariables?.AMPLIFY_SSM_ENV_CONFIG?.trim()
150
- || "";
132
+ const amplifySsmEnvConfig = process.env.AMPLIFY_SSM_ENV_CONFIG?.trim() || "";
151
133
  if (enableConsoleResponder) {
152
- const consoleChatResponder = new ConsoleChatResponderStack(dataStack, "ConsoleChatResponder", {
134
+ new ConsoleChatResponderStack(dataStack, "ConsoleChatResponder", {
153
135
  messageTable,
154
136
  messageStreamArn,
155
137
  threadTable: messageThreadTable,
156
138
  projectRoot,
157
139
  graphqlEndpoint,
158
- amplifySsmEnvConfig: jwtSsmEnvConfig || undefined,
159
140
  responseTarget: process.env.PAPYRUS_CONSOLE_RESPONSE_TARGET,
160
141
  model: process.env.PAPYRUS_CONSOLE_MODEL,
161
142
  prebuiltImageUri: process.env.PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI,
162
143
  });
163
- if (jwtSsmEnvConfig) {
164
- consoleChatResponder.responderFunction.addEnvironment("AMPLIFY_SSM_ENV_CONFIG", jwtSsmEnvConfig);
165
- }
166
- const jwtSecretSsmParam = process.env.PAPYRUS_JWT_SECRET_SSM_PARAM?.trim()
167
- || (productionAppId !== "" && amplifyAppId === productionAppId
168
- ? `/amplify/${amplifyAppId}/main-branch-cb38ada667/PAPYRUS_JWT_SECRET`
169
- : "/amplify/papyrus/ryan-sandbox-adcd88a186/PAPYRUS_JWT_SECRET");
170
- consoleChatResponder.responderFunction.addEnvironment("PAPYRUS_JWT_SECRET_SSM_PARAM", jwtSecretSsmParam);
171
144
  }
172
145
  if (enableSlackAgent) {
173
146
  const slackEventsLambda = backend.slackEvents.resources.lambda;
@@ -179,9 +152,9 @@ export function defineSiteBackend(papyrusSite) {
179
152
  backend.slackEvents.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", graphqlEndpoint);
180
153
  backend.slackEvents.addEnvironment("PAPYRUS_CONSOLE_RESPONSE_TARGET", (process.env.PAPYRUS_CONSOLE_RESPONSE_TARGET ?? "cloud").trim() || "cloud");
181
154
  backend.slackEvents.addEnvironment("PAPYRUS_SLACK_ALLOWED_USER_IDS", (process.env.PAPYRUS_SLACK_ALLOWED_USER_IDS ?? "").trim());
182
- if (jwtSsmEnvConfig) {
183
- backend.slackEvents.addEnvironment("AMPLIFY_SSM_ENV_CONFIG", jwtSsmEnvConfig);
184
- backend.slackDelivery.addEnvironment("AMPLIFY_SSM_ENV_CONFIG", jwtSsmEnvConfig);
155
+ if (amplifySsmEnvConfig) {
156
+ backend.slackEvents.addEnvironment("AMPLIFY_SSM_ENV_CONFIG", amplifySsmEnvConfig);
157
+ backend.slackDelivery.addEnvironment("AMPLIFY_SSM_ENV_CONFIG", amplifySsmEnvConfig);
185
158
  }
186
159
  backend.slackDelivery.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", graphqlEndpoint);
187
160
  backend.slackDelivery.addEnvironment("PAPYRUS_CONSOLE_RESPONSE_TARGET", (process.env.PAPYRUS_CONSOLE_RESPONSE_TARGET ?? "cloud").trim() || "cloud");
@@ -249,31 +222,13 @@ export function defineSiteBackend(papyrusSite) {
249
222
  }
250
223
  if (enableStorageBackups) {
251
224
  const storageBackupsStack = backend.createStack("storage-backups");
252
- const storageBackupVaultName = sanitizeAwsName(process.env.PAPYRUS_STORAGE_BACKUP_VAULT_NAME?.trim()
253
- || (isAmplifyProductionPipeline
254
- ? `papyrus-${productionAppId}-main-media-backup-vault`
255
- : `papyrus-${sanitizeAwsName(storageBackupsStack.stackName, 32)}-media-vault`));
256
- storageBackupVault = new backup.BackupVault(storageBackupsStack, "PapyrusStorageBackupVault", {
225
+ const storageBackupVaultName = deriveStorageBackupVaultName(siteBackendIdentity, process.env.PAPYRUS_STORAGE_BACKUP_VAULT_NAME);
226
+ const storageBackups = addStorageBackups(storageBackupsStack, {
227
+ storageBucket,
257
228
  backupVaultName: storageBackupVaultName,
258
- removalPolicy: RemovalPolicy.RETAIN,
259
- });
260
- storageBackupPlan = new backup.BackupPlan(storageBackupsStack, "PapyrusStorageBackupPlan", {
261
- backupVault: storageBackupVault,
262
- });
263
- storageBackupPlan.addRule(new backup.BackupPlanRule({
264
- ruleName: "papyrus-storage-pitr-35d",
265
- enableContinuousBackup: true,
266
- deleteAfter: Duration.days(35),
267
- }));
268
- storageBackupPlan.addRule(new backup.BackupPlanRule({
269
- ruleName: "papyrus-storage-daily-365d",
270
- scheduleExpression: events.Schedule.cron({ minute: "0", hour: "5" }),
271
- deleteAfter: Duration.days(365),
272
- }));
273
- storageBackupPlan.addSelection("PapyrusStorageBackupSelection", {
274
- allowRestores: true,
275
- resources: [backup.BackupResource.fromArn(storageBucket.bucketArn)],
276
229
  });
230
+ storageBackupVault = storageBackups.vault;
231
+ storageBackupPlan = storageBackups.plan;
277
232
  }
278
233
  // Grant S3 access on Lambda roles only (not storage bucket policies) to avoid
279
234
  // storage ↔ data nested-stack circular dependencies from allow.resource().
@@ -333,6 +288,32 @@ export function defineSiteBackend(papyrusSite) {
333
288
  actions: ["appsync:GraphQL"],
334
289
  resources: ["*"],
335
290
  }));
291
+ const rebuildSettings = rebuildTriggerSettings({
292
+ reader: site.reader,
293
+ stagingBuildEnabled: site.stagingBuild?.enabled === true,
294
+ cmsAppId: amplifyAppId,
295
+ region: Stack.of(contentActionsLambda).region,
296
+ account: Stack.of(contentActionsLambda).account,
297
+ });
298
+ for (const [name, value] of Object.entries(rebuildSettings.environment)) {
299
+ contentActionsLambda.addEnvironment(name, value);
300
+ }
301
+ if (rebuildSettings.resources.length > 0) {
302
+ contentActionsLambda.addToRolePolicy(new PolicyStatement({ actions: rebuildSettings.actions, resources: rebuildSettings.resources }));
303
+ }
304
+ if ((site.revalidateBaseUrl ?? "").trim() !== "") {
305
+ backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_BASE_URL", (site.revalidateBaseUrl ?? "").trim());
306
+ const revalidateSecretParameter = (process.env.PAPYRUS_REVALIDATE_SECRET_PARAMETER ?? "").trim();
307
+ if (!revalidateSecretParameter.startsWith("/")) {
308
+ throw new Error("revalidateBaseUrl is set but PAPYRUS_REVALIDATE_SECRET_PARAMETER is not (an SSM parameter name starting with /). The app-shell template sets it on the production branch for Pretext sites; see docs/site-hosting.md.");
309
+ }
310
+ backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_SECRET_PARAMETER", revalidateSecretParameter);
311
+ const revalidateStack = Stack.of(contentActionsLambda);
312
+ contentActionsLambda.addToRolePolicy(new PolicyStatement({
313
+ actions: ["ssm:GetParameter"],
314
+ resources: [`arn:${revalidateStack.partition}:ssm:${revalidateStack.region}:${revalidateStack.account}:parameter${revalidateSecretParameter}`],
315
+ }));
316
+ }
336
317
  grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
337
318
  grantMediaReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
338
319
  if (backend.sesInboundReceive) {
@@ -398,83 +379,25 @@ export function defineSiteBackend(papyrusSite) {
398
379
  resources: ["*"],
399
380
  }));
400
381
  const inboundEventStack = Stack.of(receiveLambda);
401
- // SES receipt rule sets are account-global; only provision on the production pipeline.
402
- if (isAmplifyProductionPipeline) {
403
- const inboundDnsZone = route53.HostedZone.fromHostedZoneAttributes(storageStack, "PapyrusInboundDnsZone", {
404
- hostedZoneId: inboundDnsZoneId,
405
- zoneName: inboundDnsZoneName,
406
- });
407
- const verifyInboundEmailDomain = new AwsCustomResource(storageStack, "VerifyInboundEmailDomain", {
408
- onCreate: {
409
- service: "SES",
410
- action: "verifyDomainIdentity",
411
- parameters: {
412
- Domain: inboundEmailDomain,
413
- },
414
- physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${inboundEmailDomain}`),
415
- },
416
- onUpdate: {
417
- service: "SES",
418
- action: "verifyDomainIdentity",
419
- parameters: {
420
- Domain: inboundEmailDomain,
421
- },
422
- physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${inboundEmailDomain}`),
423
- },
424
- policy: AwsCustomResourcePolicy.fromSdkCalls({
425
- resources: AwsCustomResourcePolicy.ANY_RESOURCE,
426
- }),
427
- timeout: Duration.minutes(2),
428
- });
429
- new route53.TxtRecord(storageStack, "PapyrusInboundEmailDomainVerification", {
430
- zone: inboundDnsZone,
431
- recordName: `_amazonses.${inboundDnsRecordName}`,
432
- ttl: Duration.minutes(5),
433
- values: [verifyInboundEmailDomain.getResponseField("VerificationToken")],
434
- });
435
- const inboundRuleSet = new ses.ReceiptRuleSet(storageStack, "PapyrusInboundEmailRuleSet", {
436
- receiptRuleSetName: `papyrus-inbound-${inboundEmailDomain.replace(/\./g, "-")}`,
437
- });
438
- inboundRuleSet.addRule("PapyrusInboundSubmissions", {
382
+ // SES allows one active receipt rule set per region, so a non-legacy site
383
+ // creates its own brand-named rule set and never activates it here.
384
+ const inboundSesPlan = planInboundEmailSes(siteBackendIdentity, enableInboundEmail, site.inboundEmailSes);
385
+ if (inboundSesPlan.createReceiptRules) {
386
+ addInboundEmailSesIntake(storageStack, {
387
+ storageBucket,
439
388
  recipients: inboundRecipients,
440
- enabled: true,
441
- scanEnabled: true,
442
- tlsPolicy: ses.TlsPolicy.REQUIRE,
443
- actions: [
444
- new sesActions.S3({
445
- bucket: storageBucket,
446
- objectKeyPrefix: "inbound-email/",
447
- }),
448
- ],
449
- });
450
- new AwsCustomResource(storageStack, "ActivateInboundEmailRuleSet", {
451
- onCreate: {
452
- service: "SES",
453
- action: "setActiveReceiptRuleSet",
454
- parameters: {
455
- RuleSetName: inboundRuleSet.receiptRuleSetName,
456
- },
457
- physicalResourceId: PhysicalResourceId.of(`activate-${inboundRuleSet.receiptRuleSetName}`),
458
- },
459
- onUpdate: {
460
- service: "SES",
461
- action: "setActiveReceiptRuleSet",
462
- parameters: {
463
- RuleSetName: inboundRuleSet.receiptRuleSetName,
464
- },
465
- physicalResourceId: PhysicalResourceId.of(`activate-${inboundRuleSet.receiptRuleSetName}`),
466
- },
467
- onDelete: {
468
- service: "SES",
469
- action: "setActiveReceiptRuleSet",
470
- parameters: {
471
- RuleSetName: null,
472
- },
473
- },
474
- policy: AwsCustomResourcePolicy.fromSdkCalls({
475
- resources: AwsCustomResourcePolicy.ANY_RESOURCE,
476
- }),
477
- timeout: Duration.minutes(2),
389
+ ruleSetName: deriveReceiptRuleSetName(siteBackendIdentity, inboundEmailDomain),
390
+ existingRuleSetName: site.inboundEmailSes?.existingReceiptRuleSetName,
391
+ ruleName: deriveReceiptRuleName(siteBackendIdentity),
392
+ activateRuleSet: inboundSesPlan.activateReceiptRuleSet,
393
+ domainIdentity: inboundSesPlan.manageDomainIdentity
394
+ ? {
395
+ domain: inboundEmailDomain,
396
+ hostedZoneId: inboundDnsZoneId,
397
+ hostedZoneName: inboundDnsZoneName,
398
+ recordName: inboundDnsRecordName,
399
+ }
400
+ : undefined,
478
401
  });
479
402
  }
480
403
  new InboundEmailStack(inboundEventStack, "InboundEmail", {