@anthusai/papyrus 1.0.0-next.9 → 1.1.0

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 (222) hide show
  1. package/amplify/auth/prospectus-google-client.js +66 -0
  2. package/amplify/auth/prospectus-google-client.ts +71 -0
  3. package/amplify/auth/redirect-urls.js +54 -0
  4. package/amplify/auth/redirect-urls.ts +66 -0
  5. package/amplify/auth/resource.js +23 -5
  6. package/amplify/auth/resource.ts +31 -5
  7. package/amplify/data/resource.js +42 -80
  8. package/amplify/data/resource.ts +42 -80
  9. package/amplify/data/schema.js +45 -62
  10. package/amplify/data/schema.ts +47 -62
  11. package/amplify/functions/category-action/handler.ts +5 -3
  12. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  13. package/amplify/functions/console-chat-responder/resource.js +0 -7
  14. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  15. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  16. package/amplify/functions/content-actions/handler.py +143 -1
  17. package/amplify/functions/content-actions/requirements.txt +2 -1
  18. package/amplify/functions/content-actions/resource.js +6 -1
  19. package/amplify/functions/content-actions/resource.ts +9 -2
  20. package/amplify/functions/email-submission-processor/handler.py +3 -1
  21. package/amplify/functions/renditions/handler.py +120 -0
  22. package/amplify/functions/renditions/requirements.txt +4 -0
  23. package/amplify/functions/ses-inbound-receive/handler.ts +32 -4
  24. package/amplify/functions/shared/email-submission.ts +109 -1
  25. package/amplify/functions/shared/python-bundle.js +42 -2
  26. package/amplify/functions/shared/python-bundle.ts +47 -2
  27. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  28. package/amplify/inbound-email/ses-intake.js +86 -0
  29. package/amplify/inbound-email/ses-intake.ts +110 -0
  30. package/amplify/plugins/registry.js +19 -0
  31. package/amplify/plugins/registry.ts +23 -0
  32. package/amplify/plugins/signup/function/handler.py +200 -0
  33. package/amplify/plugins/signup/function/resource.js +27 -0
  34. package/amplify/plugins/signup/function/resource.ts +35 -0
  35. package/amplify/plugins/signup/papyrus.plugin.js +59 -0
  36. package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
  37. package/amplify/plugins/signup/schema.js +40 -0
  38. package/amplify/plugins/signup/schema.ts +41 -0
  39. package/amplify/plugins/signup/waf.js +48 -0
  40. package/amplify/plugins/signup/waf.ts +51 -0
  41. package/amplify/plugins/types.js +1 -0
  42. package/amplify/plugins/types.ts +39 -0
  43. package/amplify/plugins/wire.js +24 -0
  44. package/amplify/plugins/wire.ts +26 -0
  45. package/amplify/publication/default-publication.js +18 -0
  46. package/amplify/publication/default-publication.ts +20 -0
  47. package/amplify/publication/invalidation-alarm.js +26 -0
  48. package/amplify/publication/invalidation-alarm.ts +30 -0
  49. package/amplify/publication/redirect-and-index.js +37 -0
  50. package/amplify/publication/renditions-function.js +74 -0
  51. package/amplify/publication/renditions-function.ts +112 -0
  52. package/amplify/publication/renditions-settings.js +51 -0
  53. package/amplify/publication/renditions-settings.ts +58 -0
  54. package/amplify/publication/reproject-on-deploy.js +73 -0
  55. package/amplify/publication/reproject-on-deploy.ts +78 -0
  56. package/amplify/publication/s3-projection-settings.js +67 -0
  57. package/amplify/publication/s3-projection-settings.ts +101 -0
  58. package/amplify/publication/s3-projection.js +151 -0
  59. package/amplify/publication/s3-projection.ts +201 -0
  60. package/amplify/resolver-waves.js +26 -0
  61. package/amplify/resolver-waves.ts +34 -0
  62. package/amplify/revalidate-invalidation-settings.js +30 -0
  63. package/amplify/revalidate-invalidation-settings.ts +35 -0
  64. package/amplify/revalidate-secret-policy.js +13 -0
  65. package/amplify/revalidate-secret-policy.ts +19 -0
  66. package/amplify/site-backend-config.js +65 -1
  67. package/amplify/site-backend-config.ts +153 -2
  68. package/amplify/site-backend-names.js +103 -0
  69. package/amplify/site-backend-names.ts +166 -0
  70. package/amplify/site-backend.js +150 -176
  71. package/amplify/site-backend.ts +189 -201
  72. package/amplify/storage-backups/construct.js +31 -0
  73. package/amplify/storage-backups/construct.ts +47 -0
  74. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
  75. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  76. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
  77. package/app/[year]/[month]/[day]/page.tsx +1 -1
  78. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
  79. package/app/api/archive/editions/route.ts +1 -1
  80. package/app/api/auth/prospectus/callback/route.ts +9 -0
  81. package/app/api/auth/prospectus/selection/route.ts +9 -0
  82. package/app/api/auth/prospectus/session/route.ts +9 -0
  83. package/app/api/auth/prospectus/sign-in/route.ts +9 -0
  84. package/app/api/auth/prospectus/sign-out/route.ts +9 -0
  85. package/app/api/cyclotron/priming-proof/route.ts +11 -0
  86. package/app/api/media/[...path]/route.ts +5 -16
  87. package/app/api/revalidate/route.ts +15 -7
  88. package/app/archive/page.tsx +1 -1
  89. package/app/articles/[slug]/page.tsx +7 -5
  90. package/app/blog-rhythm.css +278 -0
  91. package/app/cyclotron/page.tsx +13 -0
  92. package/app/globals.css +2 -0
  93. package/app/layout.tsx +16 -0
  94. package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
  95. package/app/newsroom/[section]/page.tsx +6 -3
  96. package/app/newsroom/layout.tsx +4 -9
  97. package/app/newsroom/settings/page.tsx +6 -0
  98. package/app/newsroom/work/page.tsx +10 -0
  99. package/app/page.tsx +22 -8
  100. package/app/robots.ts +2 -5
  101. package/app/tailwind.css +1 -6
  102. package/components/amplify-client-provider.tsx +5 -22
  103. package/components/archive-shell.tsx +2 -22
  104. package/components/article-video.tsx +152 -5
  105. package/components/blog-page-background.tsx +23 -6
  106. package/components/news-desk-page.tsx +4 -15
  107. package/components/news-desk-taxonomy-client.ts +90 -3
  108. package/components/newspaper.tsx +6 -2
  109. package/components/newsroom-access-gate.tsx +2 -1
  110. package/components/newsroom-board-view.tsx +99 -0
  111. package/components/newsroom-client-shell.tsx +12 -0
  112. package/components/newsroom-curation-provider.tsx +17 -0
  113. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  114. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  115. package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
  116. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  117. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  118. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
  119. package/components/newsroom-newsprint-board.css +48 -0
  120. package/components/newsroom-ops-overview.tsx +28 -11
  121. package/components/newsroom-ops-shell.tsx +7 -1
  122. package/components/newsroom-organizer-shell.tsx +130 -0
  123. package/components/newsroom-organizer.css +63 -0
  124. package/components/newsroom-references-view.tsx +92 -8
  125. package/components/papyrus-console-shell.tsx +4 -2
  126. package/components/pictogram-figure.tsx +9 -2
  127. package/components/presentation-header.tsx +135 -0
  128. package/components/presentation-rhythm-hrule.tsx +17 -0
  129. package/components/priming-proof-page.tsx +94 -0
  130. package/components/publication-cyclotron-panel.tsx +57 -0
  131. package/components/reader-auth-control.tsx +8 -5
  132. package/components/reference-curation-controls.tsx +77 -0
  133. package/components/reference-cyclotron-status.tsx +109 -0
  134. package/components/reference-priming.tsx +18 -0
  135. package/components/reference-relevance-review.tsx +75 -0
  136. package/components/settings-page.tsx +2 -1
  137. package/components/signup-entries-panel.tsx +94 -0
  138. package/components/topic-steering-workspace.tsx +78 -8
  139. package/components/use-blog-header-obstacles.ts +81 -0
  140. package/components/use-brand-fonts-ready.ts +28 -0
  141. package/components/use-near-viewport.ts +30 -0
  142. package/components/use-rhythm-overlay.ts +29 -0
  143. package/infra/amplify-app-shell.js +166 -27
  144. package/infra/amplify-app-shell.ts +180 -31
  145. package/infra/build-specs.js +23 -7
  146. package/infra/build-specs.ts +27 -10
  147. package/infra/site-config.js +248 -36
  148. package/infra/site-config.ts +313 -51
  149. package/lib/amplify-outputs-path.ts +5 -1
  150. package/lib/amplify-server-runtime.ts +36 -1
  151. package/lib/approved-priming-proof-reports.ts +6 -0
  152. package/lib/blog-feature-solver.ts +459 -0
  153. package/lib/blog-rhythm.ts +134 -0
  154. package/lib/brand-fonts-watch.ts +43 -0
  155. package/lib/brand-fonts.ts +112 -0
  156. package/lib/cached-content-repository.ts +12 -0
  157. package/lib/category-repository.ts +43 -93
  158. package/lib/content-item-lifecycle.ts +55 -0
  159. package/lib/content-repository.ts +3 -0
  160. package/lib/content-types.ts +4 -0
  161. package/lib/curation-client.ts +61 -0
  162. package/lib/edition-routes.ts +31 -29
  163. package/lib/graphql-content-repository.ts +56 -22
  164. package/lib/media-route-response.ts +45 -0
  165. package/lib/newspaper-layout.ts +3 -2
  166. package/lib/newsroom-base-path.ts +12 -3
  167. package/lib/newsroom-board.ts +99 -0
  168. package/lib/newsroom-category-drilldown.ts +3 -2
  169. package/lib/newsroom-forum-routes.ts +6 -4
  170. package/lib/newsroom-index-filters.ts +14 -10
  171. package/lib/newsroom-nav.ts +3 -1
  172. package/lib/newsroom-organizer-nav.ts +37 -0
  173. package/lib/newsroom-section-routing.ts +20 -0
  174. package/lib/oauth-redirect-priority.ts +40 -0
  175. package/lib/papyrus-web-locations.ts +7 -6
  176. package/lib/plugin-settings.ts +11 -0
  177. package/lib/presentation-footer.ts +3 -2
  178. package/lib/pretext-layout.ts +12 -3
  179. package/lib/priming-proof-report.ts +90 -0
  180. package/lib/prospectus-publication-access.ts +53 -0
  181. package/lib/prospectus-session.ts +196 -0
  182. package/lib/prospectus-sign-in-routes.ts +284 -0
  183. package/lib/prospectus-site-identity.ts +303 -0
  184. package/lib/publication-cyclotron.ts +74 -0
  185. package/lib/rate-limit-retry.ts +55 -0
  186. package/lib/reader-base-path.ts +24 -0
  187. package/lib/reader-cache-lifetime.ts +5 -0
  188. package/lib/reader-static-params.ts +85 -4
  189. package/lib/reader-storage-url.ts +9 -12
  190. package/lib/reference-policy.ts +5 -0
  191. package/lib/relevance-decisions.ts +103 -0
  192. package/lib/relevance-status.ts +63 -0
  193. package/lib/renderer.ts +7 -0
  194. package/lib/revalidate-prewarm.ts +53 -0
  195. package/lib/revalidate-secret.ts +59 -0
  196. package/lib/robots-policy.ts +14 -0
  197. package/lib/root-route-redirect.ts +16 -0
  198. package/lib/semantic-graph.ts +7 -0
  199. package/lib/shared-credentials-provider.ts +61 -0
  200. package/lib/signup-csv.ts +24 -0
  201. package/lib/site-brand.ts +59 -0
  202. package/lib/staging-gated-path.ts +23 -0
  203. package/lib/staging-preview-object.ts +21 -5
  204. package/lib/test-editor-override.ts +4 -0
  205. package/lib/video-loading.ts +22 -0
  206. package/lib/video-script.ts +14 -0
  207. package/middleware.ts +11 -19
  208. package/package.json +73 -8
  209. package/renderers/pretext/article-page.tsx +39 -3
  210. package/renderers/pretext/presentation-shared.tsx +113 -0
  211. package/renderers/pretext/presentation-shell.tsx +15 -102
  212. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  213. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  214. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  215. package/routes.manifest.json +123 -12
  216. package/src/sync.mjs +30 -6
  217. package/src/with-papyrus.d.mts +9 -0
  218. package/src/with-papyrus.mjs +48 -3
  219. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  220. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  221. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
  222. package/components/ai-elements/message.tsx +0 -360
@@ -0,0 +1,151 @@
1
+ import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
2
+ import * as acm from "aws-cdk-lib/aws-certificatemanager";
3
+ import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
4
+ import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
5
+ import { Effect, Policy, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
6
+ import { SqsDestination } from "aws-cdk-lib/aws-lambda-destinations";
7
+ import * as route53 from "aws-cdk-lib/aws-route53";
8
+ import * as s3 from "aws-cdk-lib/aws-s3";
9
+ import * as sqs from "aws-cdk-lib/aws-sqs";
10
+ import { dirname, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { assertValidPublicationConfig } from "../site-backend-config.js";
13
+ import { addRenditionsFunction } from "./renditions-function.js";
14
+ import { RENDITIONS_FUNCTION_NAME_ENV } from "./renditions-settings.js";
15
+ import { addInvalidationPathAlarm } from "./invalidation-alarm.js";
16
+ import { distributionIdFromArn, s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings.js";
17
+ const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
18
+ function createSiteBucket(scope, retainBucketOnDelete) {
19
+ return new s3.Bucket(scope, "SiteBucket", {
20
+ versioned: true,
21
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
22
+ enforceSSL: true,
23
+ encryption: s3.BucketEncryption.S3_MANAGED,
24
+ lifecycleRules: [{ noncurrentVersionExpiration: Duration.days(90) }],
25
+ removalPolicy: retainBucketOnDelete ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
26
+ autoDeleteObjects: !retainBucketOnDelete,
27
+ });
28
+ }
29
+ function createDnsValidatedCertificate(scope, aliases, hostedZone) {
30
+ const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "SiteHostedZone", {
31
+ hostedZoneId: hostedZone.hostedZoneId,
32
+ zoneName: hostedZone.hostedZoneName,
33
+ });
34
+ return new acm.DnsValidatedCertificate(scope, "SiteCertificate", {
35
+ domainName: aliases[0],
36
+ subjectAlternativeNames: aliases.slice(1),
37
+ hostedZone: zone,
38
+ region: "us-east-1",
39
+ });
40
+ }
41
+ function createDistribution(scope, props, bucket, redirectFunction) {
42
+ const aliases = props.distribution?.aliases ?? [];
43
+ let certificate;
44
+ if (aliases.length > 0) {
45
+ certificate = props.distribution?.acm !== undefined
46
+ ? createDnsValidatedCertificate(scope, aliases, props.distribution.acm)
47
+ : acm.Certificate.fromCertificateArn(scope, "SiteCertificate", props.distribution?.certificateArn ?? "");
48
+ }
49
+ return new cloudfront.Distribution(scope, "SiteDistribution", {
50
+ defaultRootObject: "index.html",
51
+ httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
52
+ enableLogging: false,
53
+ domainNames: aliases.length > 0 ? aliases : undefined,
54
+ certificate,
55
+ defaultBehavior: {
56
+ origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
57
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
58
+ compress: true,
59
+ functionAssociations: [{ function: redirectFunction, eventType: cloudfront.FunctionEventType.VIEWER_REQUEST }],
60
+ },
61
+ errorResponses: [
62
+ { httpStatus: 404, responseHttpStatus: 404, responsePagePath: "/404.html" },
63
+ { httpStatus: 403, responseHttpStatus: 404, responsePagePath: "/404.html" },
64
+ ],
65
+ });
66
+ }
67
+ export function addS3ProjectionPublication(scope, props, media) {
68
+ assertValidPublicationConfig({ kind: "s3-projection", ...props });
69
+ const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
70
+ const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
71
+ const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
72
+ runtime: cloudfront.FunctionRuntime.JS_2_0,
73
+ code: cloudfront.FunctionCode.fromFile({ filePath: redirectFunctionSourcePath }),
74
+ keyValueStore,
75
+ });
76
+ const deadLetterQueue = new sqs.Queue(scope, "ProjectionDeadLetterQueue", {
77
+ encryption: sqs.QueueEncryption.SQS_MANAGED,
78
+ retentionPeriod: Duration.days(14),
79
+ enforceSSL: true,
80
+ });
81
+ const createsDistribution = props.distribution?.create === true;
82
+ const distribution = createsDistribution ? createDistribution(scope, props, bucket, redirectFunction) : undefined;
83
+ let distributionArn;
84
+ if (distribution !== undefined) {
85
+ distributionArn = distribution.distributionArn;
86
+ }
87
+ else if (props.edgeDistributionArn !== undefined) {
88
+ distributionArn = props.edgeDistributionArn;
89
+ bucket.addToResourcePolicy(new PolicyStatement({
90
+ effect: Effect.ALLOW,
91
+ principals: [new ServicePrincipal("cloudfront.amazonaws.com")],
92
+ actions: ["s3:GetObject"],
93
+ resources: [bucket.arnForObjects("*")],
94
+ conditions: { StringEquals: { "aws:SourceArn": props.edgeDistributionArn } },
95
+ }));
96
+ }
97
+ const renditions = media?.renditions.enabled === true
98
+ ? addRenditionsFunction(scope, { siteBucket: bucket, mediaBucket: media.mediaBucket, siteFiles: media.renditions.siteFiles, code: media.renditionsCode })
99
+ : undefined;
100
+ const settingsInput = {
101
+ bucketName: bucket.bucketName,
102
+ bucketArn: bucket.bucketArn,
103
+ keyValueStoreArn: keyValueStore.keyValueStoreArn,
104
+ deadLetterQueueUrl: deadLetterQueue.queueUrl,
105
+ deadLetterQueueArn: deadLetterQueue.queueArn,
106
+ distributionArn,
107
+ distributionId: distribution?.distributionId,
108
+ publicHostName: distribution !== undefined
109
+ ? (props.distribution?.aliases?.[0] ?? distribution.distributionDomainName)
110
+ : undefined,
111
+ cache: props.cache,
112
+ projector: props.projector,
113
+ staticDir: props.staticDir,
114
+ };
115
+ const invalidatedDistributionId = distribution?.distributionId
116
+ ?? (props.edgeDistributionArn !== undefined ? distributionIdFromArn(props.edgeDistributionArn) : undefined);
117
+ if (invalidatedDistributionId !== undefined && props.cache?.invalidation !== "off") {
118
+ addInvalidationPathAlarm(scope, "EdgeInvalidationPaths", invalidatedDistributionId);
119
+ }
120
+ return {
121
+ bucket,
122
+ keyValueStore,
123
+ redirectFunction,
124
+ deadLetterQueue,
125
+ distribution,
126
+ distributionId: distribution?.distributionId,
127
+ renditions,
128
+ environment: {
129
+ ...s3ProjectionEnvironment(settingsInput),
130
+ ...(renditions !== undefined ? { [RENDITIONS_FUNCTION_NAME_ENV]: renditions.function.functionName } : {}),
131
+ },
132
+ policyStatements: s3ProjectionPolicyStatements(settingsInput).map((statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources })),
133
+ };
134
+ }
135
+ /**
136
+ * The content-actions function runs a failed or follow-up projection again as an asynchronous
137
+ * invocation of itself: one retry, and an on-failure destination (the dead letter queue) for
138
+ * what still fails. The self-invoke permission is a separate policy on the role so that the
139
+ * function does not depend on a policy that refers to the function.
140
+ */
141
+ export function addProjectionRetries(lambda, deadLetterQueue) {
142
+ lambda.configureAsyncInvoke({
143
+ retryAttempts: 1,
144
+ maxEventAge: Duration.hours(1),
145
+ onFailure: new SqsDestination(deadLetterQueue),
146
+ });
147
+ new Policy(Stack.of(lambda), "ContentActionsProjectionSelfInvoke", {
148
+ statements: [new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [lambda.functionArn] })],
149
+ roles: [lambda.role],
150
+ });
151
+ }
@@ -0,0 +1,201 @@
1
+ import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
2
+ import * as acm from "aws-cdk-lib/aws-certificatemanager";
3
+ import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
4
+ import type { Code } from "aws-cdk-lib/aws-lambda";
5
+ import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
6
+ import { Effect, Policy, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
7
+ import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
8
+ import { SqsDestination } from "aws-cdk-lib/aws-lambda-destinations";
9
+ import * as route53 from "aws-cdk-lib/aws-route53";
10
+ import * as s3 from "aws-cdk-lib/aws-s3";
11
+ import * as sqs from "aws-cdk-lib/aws-sqs";
12
+ import type { Construct } from "constructs";
13
+ import { dirname, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { assertValidPublicationConfig, type PapyrusPublicationConfig } from "../site-backend-config";
16
+ import { addRenditionsFunction, type RenditionsFunction } from "./renditions-function";
17
+ import { RENDITIONS_FUNCTION_NAME_ENV, type RenditionsPlan } from "./renditions-settings";
18
+ import { addInvalidationPathAlarm } from "./invalidation-alarm";
19
+ import { distributionIdFromArn, s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings";
20
+
21
+ const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
22
+
23
+ export type S3ProjectionPublicationProps = Omit<PapyrusPublicationConfig, "kind"> & { kind?: "s3-projection" };
24
+
25
+ export type S3ProjectionMedia = {
26
+ mediaBucket: s3.IBucket;
27
+ renditions: RenditionsPlan;
28
+ renditionsCode?: Code;
29
+ };
30
+
31
+ export type S3ProjectionPublication = {
32
+ bucket: s3.Bucket;
33
+ keyValueStore: cloudfront.KeyValueStore;
34
+ redirectFunction: cloudfront.Function;
35
+ deadLetterQueue: sqs.Queue;
36
+ distribution?: cloudfront.Distribution;
37
+ distributionId?: string;
38
+ renditions?: RenditionsFunction;
39
+ environment: Record<string, string>;
40
+ policyStatements: PolicyStatement[];
41
+ };
42
+
43
+ function createSiteBucket(scope: Construct, retainBucketOnDelete: boolean): s3.Bucket {
44
+ return new s3.Bucket(scope, "SiteBucket", {
45
+ versioned: true,
46
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
47
+ enforceSSL: true,
48
+ encryption: s3.BucketEncryption.S3_MANAGED,
49
+ lifecycleRules: [{ noncurrentVersionExpiration: Duration.days(90) }],
50
+ removalPolicy: retainBucketOnDelete ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
51
+ autoDeleteObjects: !retainBucketOnDelete,
52
+ });
53
+ }
54
+
55
+ function createDnsValidatedCertificate(
56
+ scope: Construct,
57
+ aliases: string[],
58
+ hostedZone: { hostedZoneId: string; hostedZoneName: string },
59
+ ): acm.ICertificate {
60
+ const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "SiteHostedZone", {
61
+ hostedZoneId: hostedZone.hostedZoneId,
62
+ zoneName: hostedZone.hostedZoneName,
63
+ });
64
+ return new acm.DnsValidatedCertificate(scope, "SiteCertificate", {
65
+ domainName: aliases[0],
66
+ subjectAlternativeNames: aliases.slice(1),
67
+ hostedZone: zone,
68
+ region: "us-east-1",
69
+ });
70
+ }
71
+
72
+ function createDistribution(
73
+ scope: Construct,
74
+ props: S3ProjectionPublicationProps,
75
+ bucket: s3.Bucket,
76
+ redirectFunction: cloudfront.Function,
77
+ ): cloudfront.Distribution {
78
+ const aliases = props.distribution?.aliases ?? [];
79
+ let certificate: acm.ICertificate | undefined;
80
+ if (aliases.length > 0) {
81
+ certificate = props.distribution?.acm !== undefined
82
+ ? createDnsValidatedCertificate(scope, aliases, props.distribution.acm)
83
+ : acm.Certificate.fromCertificateArn(scope, "SiteCertificate", props.distribution?.certificateArn ?? "");
84
+ }
85
+ return new cloudfront.Distribution(scope, "SiteDistribution", {
86
+ defaultRootObject: "index.html",
87
+ httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
88
+ enableLogging: false,
89
+ domainNames: aliases.length > 0 ? aliases : undefined,
90
+ certificate,
91
+ defaultBehavior: {
92
+ origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
93
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
94
+ compress: true,
95
+ functionAssociations: [{ function: redirectFunction, eventType: cloudfront.FunctionEventType.VIEWER_REQUEST }],
96
+ },
97
+ errorResponses: [
98
+ { httpStatus: 404, responseHttpStatus: 404, responsePagePath: "/404.html" },
99
+ { httpStatus: 403, responseHttpStatus: 404, responsePagePath: "/404.html" },
100
+ ],
101
+ });
102
+ }
103
+
104
+ export function addS3ProjectionPublication(
105
+ scope: Construct,
106
+ props: S3ProjectionPublicationProps,
107
+ media?: S3ProjectionMedia,
108
+ ): S3ProjectionPublication {
109
+ assertValidPublicationConfig({ kind: "s3-projection", ...props });
110
+ const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
111
+
112
+ const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
113
+ const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
114
+ runtime: cloudfront.FunctionRuntime.JS_2_0,
115
+ code: cloudfront.FunctionCode.fromFile({ filePath: redirectFunctionSourcePath }),
116
+ keyValueStore,
117
+ });
118
+ const deadLetterQueue = new sqs.Queue(scope, "ProjectionDeadLetterQueue", {
119
+ encryption: sqs.QueueEncryption.SQS_MANAGED,
120
+ retentionPeriod: Duration.days(14),
121
+ enforceSSL: true,
122
+ });
123
+
124
+ const createsDistribution = props.distribution?.create === true;
125
+ const distribution = createsDistribution ? createDistribution(scope, props, bucket, redirectFunction) : undefined;
126
+
127
+ let distributionArn: string | undefined;
128
+ if (distribution !== undefined) {
129
+ distributionArn = distribution.distributionArn;
130
+ } else if (props.edgeDistributionArn !== undefined) {
131
+ distributionArn = props.edgeDistributionArn;
132
+ bucket.addToResourcePolicy(
133
+ new PolicyStatement({
134
+ effect: Effect.ALLOW,
135
+ principals: [new ServicePrincipal("cloudfront.amazonaws.com")],
136
+ actions: ["s3:GetObject"],
137
+ resources: [bucket.arnForObjects("*")],
138
+ conditions: { StringEquals: { "aws:SourceArn": props.edgeDistributionArn } },
139
+ }),
140
+ );
141
+ }
142
+
143
+ const renditions = media?.renditions.enabled === true
144
+ ? addRenditionsFunction(scope, { siteBucket: bucket, mediaBucket: media.mediaBucket, siteFiles: media.renditions.siteFiles, code: media.renditionsCode })
145
+ : undefined;
146
+
147
+ const settingsInput = {
148
+ bucketName: bucket.bucketName,
149
+ bucketArn: bucket.bucketArn,
150
+ keyValueStoreArn: keyValueStore.keyValueStoreArn,
151
+ deadLetterQueueUrl: deadLetterQueue.queueUrl,
152
+ deadLetterQueueArn: deadLetterQueue.queueArn,
153
+ distributionArn,
154
+ distributionId: distribution?.distributionId,
155
+ publicHostName: distribution !== undefined
156
+ ? (props.distribution?.aliases?.[0] ?? distribution.distributionDomainName)
157
+ : undefined,
158
+ cache: props.cache,
159
+ projector: props.projector,
160
+ staticDir: props.staticDir,
161
+ };
162
+ const invalidatedDistributionId = distribution?.distributionId
163
+ ?? (props.edgeDistributionArn !== undefined ? distributionIdFromArn(props.edgeDistributionArn) : undefined);
164
+ if (invalidatedDistributionId !== undefined && props.cache?.invalidation !== "off") {
165
+ addInvalidationPathAlarm(scope, "EdgeInvalidationPaths", invalidatedDistributionId);
166
+ }
167
+ return {
168
+ bucket,
169
+ keyValueStore,
170
+ redirectFunction,
171
+ deadLetterQueue,
172
+ distribution,
173
+ distributionId: distribution?.distributionId,
174
+ renditions,
175
+ environment: {
176
+ ...s3ProjectionEnvironment(settingsInput),
177
+ ...(renditions !== undefined ? { [RENDITIONS_FUNCTION_NAME_ENV]: renditions.function.functionName } : {}),
178
+ },
179
+ policyStatements: s3ProjectionPolicyStatements(settingsInput).map(
180
+ (statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources }),
181
+ ),
182
+ };
183
+ }
184
+
185
+ /**
186
+ * The content-actions function runs a failed or follow-up projection again as an asynchronous
187
+ * invocation of itself: one retry, and an on-failure destination (the dead letter queue) for
188
+ * what still fails. The self-invoke permission is a separate policy on the role so that the
189
+ * function does not depend on a policy that refers to the function.
190
+ */
191
+ export function addProjectionRetries(lambda: LambdaFunction, deadLetterQueue: sqs.IQueue): void {
192
+ lambda.configureAsyncInvoke({
193
+ retryAttempts: 1,
194
+ maxEventAge: Duration.hours(1),
195
+ onFailure: new SqsDestination(deadLetterQueue),
196
+ });
197
+ new Policy(Stack.of(lambda), "ContentActionsProjectionSelfInvoke", {
198
+ statements: [new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [lambda.functionArn] })],
199
+ roles: [lambda.role!],
200
+ });
201
+ }
@@ -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
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Environment and IAM for purging our CloudFront copies of the paths a Pretext reader revalidated.
3
+ * Pure (no CDK imports) so it can be tested without synthesizing.
4
+ */
5
+ export const REVALIDATE_DISTRIBUTION_IDS_ENV = "PAPYRUS_REVALIDATE_DISTRIBUTION_IDS";
6
+ const CLOUDFRONT_DISTRIBUTION_ID = /^E[A-Z0-9]{8,}$/;
7
+ export function validateRevalidateDistributionIds(ids, revalidateBaseUrl) {
8
+ if (ids === undefined)
9
+ return [];
10
+ if (!Array.isArray(ids) || ids.length === 0)
11
+ return ["revalidateDistributionIds must be a non-empty array of CloudFront distribution ids"];
12
+ const problems = [];
13
+ for (const id of ids) {
14
+ if (typeof id !== "string" || !CLOUDFRONT_DISTRIBUTION_ID.test(id)) {
15
+ problems.push(`revalidateDistributionIds entry ${JSON.stringify(id)} is not a CloudFront distribution id`);
16
+ }
17
+ }
18
+ if ((revalidateBaseUrl ?? "").trim() === "")
19
+ problems.push("revalidateDistributionIds requires revalidateBaseUrl");
20
+ return problems;
21
+ }
22
+ export function revalidateDistributionEnvironment(ids) {
23
+ return { [REVALIDATE_DISTRIBUTION_IDS_ENV]: ids.join(",") };
24
+ }
25
+ export function revalidateInvalidationStatement(input) {
26
+ return {
27
+ actions: ["cloudfront:CreateInvalidation"],
28
+ resources: input.distributionIds.map((id) => `arn:${input.partition}:cloudfront::${input.account}:distribution/${id}`),
29
+ };
30
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Environment and IAM for purging our CloudFront copies of the paths a Pretext reader revalidated.
3
+ * Pure (no CDK imports) so it can be tested without synthesizing.
4
+ */
5
+ export const REVALIDATE_DISTRIBUTION_IDS_ENV = "PAPYRUS_REVALIDATE_DISTRIBUTION_IDS";
6
+
7
+ const CLOUDFRONT_DISTRIBUTION_ID = /^E[A-Z0-9]{8,}$/;
8
+
9
+ export function validateRevalidateDistributionIds(ids: unknown, revalidateBaseUrl: string | undefined): string[] {
10
+ if (ids === undefined) return [];
11
+ if (!Array.isArray(ids) || ids.length === 0) return ["revalidateDistributionIds must be a non-empty array of CloudFront distribution ids"];
12
+ const problems: string[] = [];
13
+ for (const id of ids) {
14
+ if (typeof id !== "string" || !CLOUDFRONT_DISTRIBUTION_ID.test(id)) {
15
+ problems.push(`revalidateDistributionIds entry ${JSON.stringify(id)} is not a CloudFront distribution id`);
16
+ }
17
+ }
18
+ if ((revalidateBaseUrl ?? "").trim() === "") problems.push("revalidateDistributionIds requires revalidateBaseUrl");
19
+ return problems;
20
+ }
21
+
22
+ export function revalidateDistributionEnvironment(ids: string[]): Record<string, string> {
23
+ return { [REVALIDATE_DISTRIBUTION_IDS_ENV]: ids.join(",") };
24
+ }
25
+
26
+ export function revalidateInvalidationStatement(input: {
27
+ partition: string;
28
+ account: string;
29
+ distributionIds: string[];
30
+ }): { actions: string[]; resources: string[] } {
31
+ return {
32
+ actions: ["cloudfront:CreateInvalidation"],
33
+ resources: input.distributionIds.map((id) => `arn:${input.partition}:cloudfront::${input.account}:distribution/${id}`),
34
+ };
35
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * IAM statement for the content-actions Lambda's read of the revalidate secret.
3
+ * Pure (no CDK imports) so the policy can be tested without synthesizing.
4
+ * The Lambda reads the parameter through boto3 get_parameters (plural), so the
5
+ * plural action must be granted alongside the singular one.
6
+ */
7
+ export const REVALIDATE_SECRET_READ_ACTIONS = ["ssm:GetParameter", "ssm:GetParameters"];
8
+ export function revalidateSecretReadStatement(input) {
9
+ return {
10
+ actions: REVALIDATE_SECRET_READ_ACTIONS,
11
+ resources: [`arn:${input.partition}:ssm:${input.region}:${input.account}:parameter${input.parameterName}`],
12
+ };
13
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * IAM statement for the content-actions Lambda's read of the revalidate secret.
3
+ * Pure (no CDK imports) so the policy can be tested without synthesizing.
4
+ * The Lambda reads the parameter through boto3 get_parameters (plural), so the
5
+ * plural action must be granted alongside the singular one.
6
+ */
7
+ export const REVALIDATE_SECRET_READ_ACTIONS = ["ssm:GetParameter", "ssm:GetParameters"];
8
+
9
+ export function revalidateSecretReadStatement(input: {
10
+ partition: string;
11
+ region: string;
12
+ account: string;
13
+ parameterName: string;
14
+ }): { actions: string[]; resources: string[] } {
15
+ return {
16
+ actions: REVALIDATE_SECRET_READ_ACTIONS,
17
+ resources: [`arn:${input.partition}:ssm:${input.region}:${input.account}:parameter${input.parameterName}`],
18
+ };
19
+ }
@@ -1 +1,65 @@
1
- export {};
1
+ export const MAXIMUM_HTML_S_MAXAGE_SECONDS = 86400;
2
+ const CLOUDFRONT_DISTRIBUTION_ARN = /^arn:aws[a-z-]*:cloudfront::\d{12}:distribution\/[A-Z0-9]+$/;
3
+ function isRelativePathWithoutParentSegments(value) {
4
+ if (value === "" || value.startsWith("/") || value.includes("\\"))
5
+ return false;
6
+ return !value.split("/").includes("..");
7
+ }
8
+ export function validatePublicationConfig(publication) {
9
+ const problems = [];
10
+ if (typeof publication !== "object" || publication === null) {
11
+ return ["publication must be an object"];
12
+ }
13
+ const config = publication;
14
+ if (config.kind !== "s3-projection") {
15
+ problems.push(`publication.kind must be "s3-projection" (got ${JSON.stringify(config.kind)})`);
16
+ }
17
+ if (config.projector !== undefined) {
18
+ if (typeof config.projector !== "string" || !isRelativePathWithoutParentSegments(config.projector) || !config.projector.endsWith(".py")) {
19
+ problems.push("publication.projector must be a relative .py path without '..'");
20
+ }
21
+ }
22
+ if (config.staticDir !== undefined) {
23
+ if (typeof config.staticDir !== "string" || !isRelativePathWithoutParentSegments(config.staticDir)) {
24
+ problems.push("publication.staticDir must be a relative path without '..'");
25
+ }
26
+ }
27
+ if (config.edgeDistributionArn !== undefined && !CLOUDFRONT_DISTRIBUTION_ARN.test(config.edgeDistributionArn)) {
28
+ problems.push("publication.edgeDistributionArn must be a CloudFront distribution ARN (arn:aws:cloudfront::<account>:distribution/<id>)");
29
+ }
30
+ if (config.reprojectOnDeploy !== undefined && typeof config.reprojectOnDeploy !== "boolean") {
31
+ problems.push("publication.reprojectOnDeploy must be a boolean");
32
+ }
33
+ const cache = config.cache;
34
+ if (cache !== undefined) {
35
+ const lifetime = cache.htmlSMaxAgeSeconds;
36
+ if (lifetime !== undefined && (!Number.isInteger(lifetime) || lifetime < 0 || lifetime > MAXIMUM_HTML_S_MAXAGE_SECONDS)) {
37
+ problems.push(`publication.cache.htmlSMaxAgeSeconds must be an integer from 0 to ${MAXIMUM_HTML_S_MAXAGE_SECONDS}`);
38
+ }
39
+ if (cache.invalidation !== undefined && cache.invalidation !== "targeted" && cache.invalidation !== "off") {
40
+ problems.push('publication.cache.invalidation must be "targeted" or "off"');
41
+ }
42
+ }
43
+ const distribution = config.distribution;
44
+ if (distribution !== undefined) {
45
+ if (config.edgeDistributionArn !== undefined && distribution.create === true) {
46
+ problems.push("publication.edgeDistributionArn and publication.distribution.create are mutually exclusive");
47
+ }
48
+ const aliases = distribution.aliases ?? [];
49
+ if (aliases.length > 0 && distribution.create !== true) {
50
+ problems.push("publication.distribution.aliases require distribution.create to be true");
51
+ }
52
+ if (aliases.length > 0 && !distribution.certificateArn && !distribution.acm) {
53
+ problems.push("publication.distribution.aliases require certificateArn or acm");
54
+ }
55
+ if (distribution.acm !== undefined && (!distribution.acm.hostedZoneId || !distribution.acm.hostedZoneName)) {
56
+ problems.push("publication.distribution.acm requires hostedZoneId and hostedZoneName");
57
+ }
58
+ }
59
+ return problems;
60
+ }
61
+ export function assertValidPublicationConfig(publication) {
62
+ const problems = validatePublicationConfig(publication);
63
+ if (problems.length > 0)
64
+ throw new Error(`Invalid backend.publication: ${problems.join("; ")}`);
65
+ }