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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +228 -50
  75. package/infra/amplify-app-shell.ts +256 -53
  76. package/infra/build-specs.js +15 -5
  77. package/infra/build-specs.ts +18 -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 +236 -11
  82. package/infra/site-config.ts +276 -15
  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,24 +1,21 @@
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";
3
+ import type * as backup from "aws-cdk-lib/aws-backup";
5
4
  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
5
  import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
11
- import { CfnEventSourceMapping, CfnFunction, Function as LambdaFunction, FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
6
+ import { CfnEventSourceMapping, Function as LambdaFunction, FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
12
7
  import * as s3 from "aws-cdk-lib/aws-s3";
13
8
  import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aws-s3vectors";
9
+ import type { Construct } from "constructs";
14
10
  import { dirname, resolve } from "node:path";
15
11
  import { fileURLToPath } from "node:url";
16
- import { authConfigFromEnv, defineSiteAuth } from "./auth/resource";
12
+ import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves";
13
+ import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
14
+ import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource";
17
15
  import { data } from "./data/resource";
18
16
  import { assignmentAction } from "./functions/assignment-action/resource";
19
17
  import { categoryAction } from "./functions/category-action/resource";
20
18
  import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource";
21
- import { graphqlJwtAuthorizer } from "./functions/graphql-jwt-authorizer/resource";
22
19
  import { contentActions } from "./functions/content-actions/resource";
23
20
  import { knowledgeQuery } from "./functions/knowledge-query/resource";
24
21
  import { manageUserRole } from "./functions/manage-user-role/resource";
@@ -30,7 +27,19 @@ import { emailSubmissionProcessor } from "./functions/email-submission-processor
30
27
  import { sesInboundReceive } from "./functions/ses-inbound-receive/resource";
31
28
  import { slackDelivery } from "./functions/slack-delivery/resource";
32
29
  import { slackEvents } from "./functions/slack-events/resource";
30
+ import { addInboundEmailSesIntake } from "./inbound-email/ses-intake";
33
31
  import { InboundEmailStack } from "./inbound-email/stack";
32
+ import {
33
+ deriveKnowledgeVectorIndexName,
34
+ deriveReceiptRuleName,
35
+ deriveReceiptRuleSetName,
36
+ deriveStorageBackupVaultName,
37
+ isLegacyProductionPipeline,
38
+ planInboundEmailSes,
39
+ resolveSiteBackendFeatureFlags,
40
+ type SiteBackendIdentity,
41
+ } from "./site-backend-names";
42
+ import { addStorageBackups } from "./storage-backups/construct";
34
43
  import { storage } from "./storage/resource";
35
44
  import type { PapyrusSite } from "../lib/define-site";
36
45
 
@@ -43,57 +52,39 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
43
52
 
44
53
  const amplifyBranch = (process.env.AWS_BRANCH ?? "").trim();
45
54
  const amplifyAppId = (process.env.AWS_APP_ID ?? "").trim();
46
- // Amplify app id whose `main` branch is this site's production pipeline (owns
47
- // account-global resources: SES rule set, backup vault, legacy S3 Vectors name).
55
+ // Amplify app id whose `main` branch is the original p.apyr.us backend; only
56
+ // that app keeps the historical account-global names (see site-backend-names.ts).
48
57
  const productionAppId = (site.productionAppId ?? "").trim();
49
- const isAmplifyProductionPipeline =
50
- amplifyBranch === "main" && productionAppId !== "" && amplifyAppId === productionAppId;
51
-
52
- function readEnvFlag(name: string, defaultValue: boolean): boolean {
53
- const raw = (process.env[name] ?? "").trim().toLowerCase();
54
- if (["1", "true", "yes", "on"].includes(raw)) return true;
55
- if (["0", "false", "no", "off"].includes(raw)) return false;
56
- return defaultValue;
57
- }
58
-
59
- function sanitizeAwsName(value: string, maxLength = 50): string {
60
- return value
61
- .toLowerCase()
62
- .replace(/[^a-z0-9-]/g, "-")
63
- .replace(/-+/g, "-")
64
- .replace(/^-|-$/g, "")
65
- .slice(0, maxLength);
66
- }
67
-
68
- // S3 Vectors index names are account-global. Keep the original name for the
69
- // p.apyr.us production app so its existing index + data are not orphaned;
70
- // namespace every other app's index by site brand so a second CMS app can
71
- // deploy into the same account without colliding.
72
- const papyrusSiteBrand = (
73
- site.brandId
74
- ?? process.env.PAPYRUS_SITE_BRAND
75
- ?? process.env.NEXT_PUBLIC_PAPYRUS_SITE_BRAND
76
- ?? "papyrus"
77
- ).trim().toLowerCase();
78
- const knowledgeVectorIndexName =
79
- productionAppId !== "" && amplifyAppId === productionAppId
80
- ? "papyrus-knowledge"
81
- : `papyrus-knowledge-${sanitizeAwsName(papyrusSiteBrand, 40)}`;
82
-
83
- // SES active receipt rule sets and shared backup vault names are account-global.
84
- // Keep them on the production pipeline only unless explicitly opted in.
85
- // A feature flag in the site config wins over the environment.
86
- const enableInboundEmail = site.features?.inboundEmail ?? readEnvFlag("PAPYRUS_ENABLE_INBOUND_EMAIL", isAmplifyProductionPipeline);
87
- const enableSlackAgent = site.features?.slack ?? readEnvFlag("PAPYRUS_ENABLE_SLACK", false);
88
- const enableStorageBackups = site.features?.storageBackups ?? readEnvFlag("PAPYRUS_ENABLE_STORAGE_BACKUPS", isAmplifyProductionPipeline);
89
-
58
+ const siteBackendIdentity: SiteBackendIdentity = {
59
+ brandId: (
60
+ site.brandId
61
+ ?? process.env.PAPYRUS_SITE_BRAND
62
+ ?? process.env.NEXT_PUBLIC_PAPYRUS_SITE_BRAND
63
+ ?? "papyrus"
64
+ ).trim().toLowerCase(),
65
+ amplifyAppId,
66
+ amplifyBranch,
67
+ productionAppId,
68
+ };
69
+ const isAmplifyProductionPipeline = isLegacyProductionPipeline(siteBackendIdentity);
70
+ const knowledgeVectorIndexName = deriveKnowledgeVectorIndexName(siteBackendIdentity);
71
+ const featureFlags = resolveSiteBackendFeatureFlags(site.features, process.env, siteBackendIdentity);
72
+ const enableInboundEmail = featureFlags.inboundEmail;
73
+ const enableSlackAgent = featureFlags.slack;
74
+ const enableStorageBackups = featureFlags.storageBackups;
75
+
76
+ const baseAuthConfig = site.auth ?? authConfigFromEnv();
77
+ const activeBrand = (papyrusSite.brands ?? []).find((brand) => brand.id.toLowerCase() === siteBackendIdentity.brandId);
78
+ const authConfig = {
79
+ ...baseAuthConfig,
80
+ newsroomBasePath: baseAuthConfig.newsroomBasePath ?? activeBrand?.newsroomBasePath,
81
+ };
90
82
  const backend = defineBackend({
91
83
  assignmentAction,
92
- auth: defineSiteAuth(site.auth ?? authConfigFromEnv()),
84
+ auth: defineSiteAuth(authConfig),
93
85
  categoryAction,
94
86
  contentActions,
95
87
  data,
96
- graphqlJwtAuthorizer,
97
88
  knowledgeQuery,
98
89
  manageUserRole,
99
90
  modelAttachmentUpload,
@@ -107,10 +98,25 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
107
98
  storage,
108
99
  });
109
100
 
101
+ orderModelStacksInWaves(
102
+ backend.data.resources.graphqlApi.node.scope as Construct,
103
+ Object.keys(backend.data.resources.tables),
104
+ site.resolverWaveSize ?? DEFAULT_RESOLVER_WAVE_SIZE,
105
+ );
106
+
107
+ const requestedCognitoDomainPrefix = (authConfig.cognitoDomainPrefix ?? "").trim();
108
+ if (
109
+ authConfig.applyCognitoDomainPrefix === true
110
+ && requestedCognitoDomainPrefix !== ""
111
+ && authConfig.disableGoogleOAuth !== true
112
+ ) {
113
+ applyCognitoDomainPrefix(backend.auth.stack, requestedCognitoDomainPrefix);
114
+ }
115
+
110
116
  const amplifyBackendDir = dirname(fileURLToPath(import.meta.url));
111
117
  // Repo root in a Papyrus checkout; package root when installed from npm.
112
118
  const projectRoot = resolve(amplifyBackendDir, "..");
113
- const enableConsoleResponder = site.features?.consoleResponder ?? readEnvFlag("PAPYRUS_ENABLE_CONSOLE_RESPONDER", true);
119
+ const enableConsoleResponder = featureFlags.consoleResponder;
114
120
 
115
121
  if (
116
122
  enableConsoleResponder
@@ -159,43 +165,19 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
159
165
  const messageThreadTable = backend.data.resources.tables.MessageThread;
160
166
  const dataStack = Stack.of(messageTable);
161
167
  const graphqlEndpoint = backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl;
162
- const jwtAuthorizerCfn = backend.graphqlJwtAuthorizer.resources.lambda.node.defaultChild as CfnFunction | undefined;
163
- const jwtAuthorizerEnvironment = jwtAuthorizerCfn?.environment;
164
- const jwtAuthorizerVariables =
165
- jwtAuthorizerEnvironment
166
- && typeof jwtAuthorizerEnvironment === "object"
167
- && "variables" in jwtAuthorizerEnvironment
168
- ? (jwtAuthorizerEnvironment as { variables?: Record<string, string> }).variables
169
- : undefined;
170
- const jwtSsmEnvConfig =
171
- process.env.AMPLIFY_SSM_ENV_CONFIG?.trim()
172
- || jwtAuthorizerVariables?.AMPLIFY_SSM_ENV_CONFIG?.trim()
173
- || "";
168
+ const amplifySsmEnvConfig = process.env.AMPLIFY_SSM_ENV_CONFIG?.trim() || "";
174
169
 
175
170
  if (enableConsoleResponder) {
176
- const consoleChatResponder = new ConsoleChatResponderStack(dataStack, "ConsoleChatResponder", {
171
+ new ConsoleChatResponderStack(dataStack, "ConsoleChatResponder", {
177
172
  messageTable,
178
173
  messageStreamArn,
179
174
  threadTable: messageThreadTable,
180
175
  projectRoot,
181
176
  graphqlEndpoint,
182
- amplifySsmEnvConfig: jwtSsmEnvConfig || undefined,
183
177
  responseTarget: process.env.PAPYRUS_CONSOLE_RESPONSE_TARGET,
184
178
  model: process.env.PAPYRUS_CONSOLE_MODEL,
185
179
  prebuiltImageUri: process.env.PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI,
186
180
  });
187
- if (jwtSsmEnvConfig) {
188
- consoleChatResponder.responderFunction.addEnvironment("AMPLIFY_SSM_ENV_CONFIG", jwtSsmEnvConfig);
189
- }
190
- const jwtSecretSsmParam =
191
- process.env.PAPYRUS_JWT_SECRET_SSM_PARAM?.trim()
192
- || (productionAppId !== "" && amplifyAppId === productionAppId
193
- ? `/amplify/${amplifyAppId}/main-branch-cb38ada667/PAPYRUS_JWT_SECRET`
194
- : "/amplify/papyrus/ryan-sandbox-adcd88a186/PAPYRUS_JWT_SECRET");
195
- consoleChatResponder.responderFunction.addEnvironment(
196
- "PAPYRUS_JWT_SECRET_SSM_PARAM",
197
- jwtSecretSsmParam,
198
- );
199
181
  }
200
182
 
201
183
  if (enableSlackAgent) {
@@ -216,9 +198,9 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
216
198
  "PAPYRUS_SLACK_ALLOWED_USER_IDS",
217
199
  (process.env.PAPYRUS_SLACK_ALLOWED_USER_IDS ?? "").trim(),
218
200
  );
219
- if (jwtSsmEnvConfig) {
220
- backend.slackEvents.addEnvironment("AMPLIFY_SSM_ENV_CONFIG", jwtSsmEnvConfig);
221
- backend.slackDelivery.addEnvironment("AMPLIFY_SSM_ENV_CONFIG", jwtSsmEnvConfig);
201
+ if (amplifySsmEnvConfig) {
202
+ backend.slackEvents.addEnvironment("AMPLIFY_SSM_ENV_CONFIG", amplifySsmEnvConfig);
203
+ backend.slackDelivery.addEnvironment("AMPLIFY_SSM_ENV_CONFIG", amplifySsmEnvConfig);
222
204
  }
223
205
  backend.slackDelivery.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", graphqlEndpoint);
224
206
  backend.slackDelivery.addEnvironment(
@@ -306,41 +288,17 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
306
288
 
307
289
  if (enableStorageBackups) {
308
290
  const storageBackupsStack = backend.createStack("storage-backups");
309
- const storageBackupVaultName = sanitizeAwsName(
310
- process.env.PAPYRUS_STORAGE_BACKUP_VAULT_NAME?.trim()
311
- || (isAmplifyProductionPipeline
312
- ? `papyrus-${productionAppId}-main-media-backup-vault`
313
- : `papyrus-${sanitizeAwsName(storageBackupsStack.stackName, 32)}-media-vault`),
291
+ const storageBackupVaultName = deriveStorageBackupVaultName(
292
+ siteBackendIdentity,
293
+ process.env.PAPYRUS_STORAGE_BACKUP_VAULT_NAME,
314
294
  );
315
295
 
316
- storageBackupVault = new backup.BackupVault(storageBackupsStack, "PapyrusStorageBackupVault", {
296
+ const storageBackups = addStorageBackups(storageBackupsStack, {
297
+ storageBucket,
317
298
  backupVaultName: storageBackupVaultName,
318
- removalPolicy: RemovalPolicy.RETAIN,
319
- });
320
- storageBackupPlan = new backup.BackupPlan(storageBackupsStack, "PapyrusStorageBackupPlan", {
321
- backupVault: storageBackupVault,
322
- });
323
-
324
- storageBackupPlan.addRule(
325
- new backup.BackupPlanRule({
326
- ruleName: "papyrus-storage-pitr-35d",
327
- enableContinuousBackup: true,
328
- deleteAfter: Duration.days(35),
329
- }),
330
- );
331
-
332
- storageBackupPlan.addRule(
333
- new backup.BackupPlanRule({
334
- ruleName: "papyrus-storage-daily-365d",
335
- scheduleExpression: events.Schedule.cron({ minute: "0", hour: "5" }),
336
- deleteAfter: Duration.days(365),
337
- }),
338
- );
339
-
340
- storageBackupPlan.addSelection("PapyrusStorageBackupSelection", {
341
- allowRestores: true,
342
- resources: [backup.BackupResource.fromArn(storageBucket.bucketArn)],
343
299
  });
300
+ storageBackupVault = storageBackups.vault;
301
+ storageBackupPlan = storageBackups.plan;
344
302
  }
345
303
 
346
304
  // Grant S3 access on Lambda roles only (not storage bucket policies) to avoid
@@ -414,6 +372,38 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
414
372
  resources: ["*"],
415
373
  }),
416
374
  );
375
+ const rebuildSettings = rebuildTriggerSettings({
376
+ reader: site.reader,
377
+ stagingBuildEnabled: site.stagingBuild?.enabled === true,
378
+ cmsAppId: amplifyAppId,
379
+ region: Stack.of(contentActionsLambda).region,
380
+ account: Stack.of(contentActionsLambda).account,
381
+ });
382
+ for (const [name, value] of Object.entries(rebuildSettings.environment)) {
383
+ contentActionsLambda.addEnvironment(name, value);
384
+ }
385
+ if (rebuildSettings.resources.length > 0) {
386
+ contentActionsLambda.addToRolePolicy(
387
+ new PolicyStatement({ actions: rebuildSettings.actions, resources: rebuildSettings.resources }),
388
+ );
389
+ }
390
+ if ((site.revalidateBaseUrl ?? "").trim() !== "") {
391
+ backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_BASE_URL", (site.revalidateBaseUrl ?? "").trim());
392
+ const revalidateSecretParameter = (process.env.PAPYRUS_REVALIDATE_SECRET_PARAMETER ?? "").trim();
393
+ if (!revalidateSecretParameter.startsWith("/")) {
394
+ throw new Error(
395
+ "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.",
396
+ );
397
+ }
398
+ backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_SECRET_PARAMETER", revalidateSecretParameter);
399
+ const revalidateStack = Stack.of(contentActionsLambda);
400
+ contentActionsLambda.addToRolePolicy(
401
+ new PolicyStatement({
402
+ actions: ["ssm:GetParameter"],
403
+ resources: [`arn:${revalidateStack.partition}:ssm:${revalidateStack.region}:${revalidateStack.account}:parameter${revalidateSecretParameter}`],
404
+ }),
405
+ );
406
+ }
417
407
  grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda as LambdaFunction);
418
408
  grantMediaReadWriteDelete(backend.modelAttachmentUpload.resources.lambda as LambdaFunction);
419
409
  if (backend.sesInboundReceive) {
@@ -507,86 +497,26 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
507
497
 
508
498
  const inboundEventStack = Stack.of(receiveLambda);
509
499
 
510
- // SES receipt rule sets are account-global; only provision on the production pipeline.
511
- if (isAmplifyProductionPipeline) {
512
- const inboundDnsZone = route53.HostedZone.fromHostedZoneAttributes(storageStack, "PapyrusInboundDnsZone", {
513
- hostedZoneId: inboundDnsZoneId,
514
- zoneName: inboundDnsZoneName,
515
- });
516
- const verifyInboundEmailDomain = new AwsCustomResource(storageStack, "VerifyInboundEmailDomain", {
517
- onCreate: {
518
- service: "SES",
519
- action: "verifyDomainIdentity",
520
- parameters: {
521
- Domain: inboundEmailDomain,
522
- },
523
- physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${inboundEmailDomain}`),
524
- },
525
- onUpdate: {
526
- service: "SES",
527
- action: "verifyDomainIdentity",
528
- parameters: {
529
- Domain: inboundEmailDomain,
530
- },
531
- physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${inboundEmailDomain}`),
532
- },
533
- policy: AwsCustomResourcePolicy.fromSdkCalls({
534
- resources: AwsCustomResourcePolicy.ANY_RESOURCE,
535
- }),
536
- timeout: Duration.minutes(2),
537
- });
538
- new route53.TxtRecord(storageStack, "PapyrusInboundEmailDomainVerification", {
539
- zone: inboundDnsZone,
540
- recordName: `_amazonses.${inboundDnsRecordName}`,
541
- ttl: Duration.minutes(5),
542
- values: [verifyInboundEmailDomain.getResponseField("VerificationToken")],
543
- });
544
-
545
- const inboundRuleSet = new ses.ReceiptRuleSet(storageStack, "PapyrusInboundEmailRuleSet", {
546
- receiptRuleSetName: `papyrus-inbound-${inboundEmailDomain.replace(/\./g, "-")}`,
547
- });
548
- inboundRuleSet.addRule("PapyrusInboundSubmissions", {
549
- recipients: inboundRecipients,
550
- enabled: true,
551
- scanEnabled: true,
552
- tlsPolicy: ses.TlsPolicy.REQUIRE,
553
- actions: [
554
- new sesActions.S3({
555
- bucket: storageBucket,
556
- objectKeyPrefix: "inbound-email/",
557
- }),
558
- ],
559
- });
560
-
561
- new AwsCustomResource(storageStack, "ActivateInboundEmailRuleSet", {
562
- onCreate: {
563
- service: "SES",
564
- action: "setActiveReceiptRuleSet",
565
- parameters: {
566
- RuleSetName: inboundRuleSet.receiptRuleSetName,
567
- },
568
- physicalResourceId: PhysicalResourceId.of(`activate-${inboundRuleSet.receiptRuleSetName}`),
569
- },
570
- onUpdate: {
571
- service: "SES",
572
- action: "setActiveReceiptRuleSet",
573
- parameters: {
574
- RuleSetName: inboundRuleSet.receiptRuleSetName,
575
- },
576
- physicalResourceId: PhysicalResourceId.of(`activate-${inboundRuleSet.receiptRuleSetName}`),
577
- },
578
- onDelete: {
579
- service: "SES",
580
- action: "setActiveReceiptRuleSet",
581
- parameters: {
582
- RuleSetName: null,
583
- },
584
- },
585
- policy: AwsCustomResourcePolicy.fromSdkCalls({
586
- resources: AwsCustomResourcePolicy.ANY_RESOURCE,
587
- }),
588
- timeout: Duration.minutes(2),
589
- });
500
+ // SES allows one active receipt rule set per region, so a non-legacy site
501
+ // creates its own brand-named rule set and never activates it here.
502
+ const inboundSesPlan = planInboundEmailSes(siteBackendIdentity, enableInboundEmail, site.inboundEmailSes);
503
+ if (inboundSesPlan.createReceiptRules) {
504
+ addInboundEmailSesIntake(storageStack, {
505
+ storageBucket,
506
+ recipients: inboundRecipients,
507
+ ruleSetName: deriveReceiptRuleSetName(siteBackendIdentity, inboundEmailDomain),
508
+ existingRuleSetName: site.inboundEmailSes?.existingReceiptRuleSetName,
509
+ ruleName: deriveReceiptRuleName(siteBackendIdentity),
510
+ activateRuleSet: inboundSesPlan.activateReceiptRuleSet,
511
+ domainIdentity: inboundSesPlan.manageDomainIdentity
512
+ ? {
513
+ domain: inboundEmailDomain,
514
+ hostedZoneId: inboundDnsZoneId,
515
+ hostedZoneName: inboundDnsZoneName,
516
+ recordName: inboundDnsRecordName,
517
+ }
518
+ : undefined,
519
+ });
590
520
  }
591
521
 
592
522
  new InboundEmailStack(inboundEventStack, "InboundEmail", {
@@ -11,6 +11,9 @@ export const storage = defineStorage({
11
11
  "corpora/*": [
12
12
  allow.groups(["editor", "admin"]).to(["read"]),
13
13
  ],
14
+ "preview/*": [
15
+ allow.groups(["editor", "admin"]).to(["read"]),
16
+ ],
14
17
  "newsroom/*": [
15
18
  allow.groups(["editor", "admin"]).to(["read", "write", "delete"]),
16
19
  ],
@@ -12,6 +12,9 @@ export const storage = defineStorage({
12
12
  "corpora/*": [
13
13
  allow.groups(["editor", "admin"]).to(["read"]),
14
14
  ],
15
+ "preview/*": [
16
+ allow.groups(["editor", "admin"]).to(["read"]),
17
+ ],
15
18
  "newsroom/*": [
16
19
  allow.groups(["editor", "admin"]).to(["read", "write", "delete"]),
17
20
  ],
@@ -0,0 +1,31 @@
1
+ import { Duration, RemovalPolicy } from "aws-cdk-lib";
2
+ import * as backup from "aws-cdk-lib/aws-backup";
3
+ import * as events from "aws-cdk-lib/aws-events";
4
+ /**
5
+ * Adds the resources directly to `scope` (no wrapper construct) so the logical
6
+ * ids of the live p.apyr.us backup stack stay unchanged.
7
+ */
8
+ export function addStorageBackups(scope, props) {
9
+ const vault = new backup.BackupVault(scope, "PapyrusStorageBackupVault", {
10
+ backupVaultName: props.backupVaultName,
11
+ removalPolicy: RemovalPolicy.RETAIN,
12
+ });
13
+ const plan = new backup.BackupPlan(scope, "PapyrusStorageBackupPlan", {
14
+ backupVault: vault,
15
+ });
16
+ plan.addRule(new backup.BackupPlanRule({
17
+ ruleName: "papyrus-storage-pitr-35d",
18
+ enableContinuousBackup: true,
19
+ deleteAfter: Duration.days(35),
20
+ }));
21
+ plan.addRule(new backup.BackupPlanRule({
22
+ ruleName: "papyrus-storage-daily-365d",
23
+ scheduleExpression: events.Schedule.cron({ minute: "0", hour: "5" }),
24
+ deleteAfter: Duration.days(365),
25
+ }));
26
+ plan.addSelection("PapyrusStorageBackupSelection", {
27
+ allowRestores: true,
28
+ resources: [backup.BackupResource.fromArn(props.storageBucket.bucketArn)],
29
+ });
30
+ return { plan, vault };
31
+ }
@@ -0,0 +1,47 @@
1
+ import { Duration, RemovalPolicy } from "aws-cdk-lib";
2
+ import * as backup from "aws-cdk-lib/aws-backup";
3
+ import * as events from "aws-cdk-lib/aws-events";
4
+ import type * as s3 from "aws-cdk-lib/aws-s3";
5
+ import type { Construct } from "constructs";
6
+
7
+ export type StorageBackupsProps = {
8
+ storageBucket: s3.IBucket;
9
+ backupVaultName: string;
10
+ };
11
+
12
+ /**
13
+ * Adds the resources directly to `scope` (no wrapper construct) so the logical
14
+ * ids of the live p.apyr.us backup stack stay unchanged.
15
+ */
16
+ export function addStorageBackups(scope: Construct, props: StorageBackupsProps) {
17
+ const vault = new backup.BackupVault(scope, "PapyrusStorageBackupVault", {
18
+ backupVaultName: props.backupVaultName,
19
+ removalPolicy: RemovalPolicy.RETAIN,
20
+ });
21
+ const plan = new backup.BackupPlan(scope, "PapyrusStorageBackupPlan", {
22
+ backupVault: vault,
23
+ });
24
+
25
+ plan.addRule(
26
+ new backup.BackupPlanRule({
27
+ ruleName: "papyrus-storage-pitr-35d",
28
+ enableContinuousBackup: true,
29
+ deleteAfter: Duration.days(35),
30
+ }),
31
+ );
32
+
33
+ plan.addRule(
34
+ new backup.BackupPlanRule({
35
+ ruleName: "papyrus-storage-daily-365d",
36
+ scheduleExpression: events.Schedule.cron({ minute: "0", hour: "5" }),
37
+ deleteAfter: Duration.days(365),
38
+ }),
39
+ );
40
+
41
+ plan.addSelection("PapyrusStorageBackupSelection", {
42
+ allowRestores: true,
43
+ resources: [backup.BackupResource.fromArn(props.storageBucket.bucketArn)],
44
+ });
45
+
46
+ return { plan, vault };
47
+ }
@@ -0,0 +1,8 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { streamPreviewObject } from "../../../lib/staging-preview-object";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET(request: NextRequest): Promise<Response> {
7
+ return streamPreviewObject(request.nextUrl.pathname);
8
+ }
@@ -52,10 +52,12 @@ export default async function DateScopedArticlePage({ params }: DateScopedArticl
52
52
  editionFooter={{
53
53
  editionBasePath,
54
54
  entries: buildPresentationFooterEntries(content),
55
+ sections: content.sections.map((section) => ({ key: section.key, label: section.label })),
55
56
  subtitle: SITE_BRAND.footerSubtitleOverride ?? (content.description?.trim() || "Inside Papyrus"),
56
57
  title: SITE_BRAND.footerTitle,
57
58
  }}
58
59
  item={item}
60
+ videoScript={content.videoScripts?.[item.slug] ?? null}
59
61
  backHref={`${editionBasePath}#${item.slug}`}
60
62
  />
61
63
  );
@@ -1,4 +1,5 @@
1
1
  import { notFound, redirect } from "next/navigation";
2
+ import { siteServesNextReaderRoutes } from "../../../../lib/reader-static-params";
2
3
  import { getSiteRenderer } from "../../../../lib/site-renderer";
3
4
  import type { PresentationTarget } from "../../../../renderers/pretext";
4
5
  import { loadCachedEditionContent } from "../../../../lib/cached-content-repository";
@@ -22,6 +23,7 @@ export async function EditionRoutePage({
22
23
  lockedPresentation,
23
24
  target = { kind: "edition" },
24
25
  }: EditionRoutePageProps) {
26
+ if (!siteServesNextReaderRoutes()) notFound();
25
27
  const route = parseEditionDateRoute({ year, month, day });
26
28
  if (!route) notFound();
27
29
  if (!route.isCanonical) redirect(route.canonicalPath);
@@ -1,4 +1,5 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { buildMediaRouteResponse } from "../../../../lib/media-route-response";
2
3
  import { isGuestReadableStoragePath, signStorageUrl } from "../../../../lib/reader-storage-url";
3
4
 
4
5
  export const dynamic = "force-dynamic";
@@ -16,20 +17,8 @@ export async function GET(_request: Request, context: MediaRouteContext) {
16
17
  return NextResponse.json({ error: "Forbidden storage path." }, { status: 403 });
17
18
  }
18
19
 
19
- const signedUrl = await signStorageUrl(storagePath);
20
- const upstream = await fetch(signedUrl, { cache: "no-store" });
21
- if (!upstream.ok) {
22
- return NextResponse.json(
23
- { error: `Upstream media fetch failed (${upstream.status}).` },
24
- { status: upstream.status === 404 ? 404 : 502 },
25
- );
26
- }
27
-
28
- const headers = new Headers();
29
- const contentType = upstream.headers.get("content-type");
30
- if (contentType) {
31
- headers.set("content-type", contentType);
32
- }
33
- headers.set("cache-control", "public, max-age=3600, stale-while-revalidate=86400");
34
- return new NextResponse(upstream.body, { status: 200, headers });
20
+ return buildMediaRouteResponse(storagePath, {
21
+ signStorageUrl,
22
+ fetchUpstream: (signedUrl) => fetch(signedUrl, { cache: "no-store" }),
23
+ });
35
24
  }
@@ -1,3 +1,4 @@
1
+ import { toPublicReaderPath } from "../../../lib/reader-base-path";
1
2
  import { revalidatePath, revalidateTag } from "next/cache";
2
3
  import { NextResponse } from "next/server";
3
4
  import {
@@ -8,6 +9,7 @@ import {
8
9
  editionContentCacheTag,
9
10
  editionItemCacheTag,
10
11
  } from "../../../lib/cached-content-repository";
12
+ import { isAuthorizedRevalidateRequest } from "../../../lib/revalidate-secret";
11
13
  import { getEditionArticlePath, getEditionDatePath } from "../../../lib/edition-routes";
12
14
 
13
15
  export const dynamic = "force-dynamic";
@@ -21,9 +23,8 @@ type RevalidateRequestBody = {
21
23
  };
22
24
 
23
25
  export async function POST(request: Request) {
24
- const configuredSecret = process.env.PAPYRUS_REVALIDATE_SECRET?.trim();
25
- const providedSecret = request.headers.get("x-papyrus-revalidate-secret")?.trim();
26
- if (!configuredSecret || providedSecret !== configuredSecret) {
26
+ const providedSecret = request.headers.get("x-papyrus-revalidate-secret");
27
+ if (!(await isAuthorizedRevalidateRequest(providedSecret))) {
27
28
  return NextResponse.json({ ok: false, error: "Unauthorized." }, { status: 401 });
28
29
  }
29
30
 
@@ -44,8 +45,8 @@ export async function POST(request: Request) {
44
45
  if (!normalized) continue;
45
46
  revalidateTag(articleCacheTag(normalized));
46
47
  revalidatedTags.add(articleCacheTag(normalized));
47
- revalidatePath(`/articles/${encodeURIComponent(normalized)}`);
48
- revalidatedPaths.add(`/articles/${encodeURIComponent(normalized)}`);
48
+ revalidatePath(toPublicReaderPath(`/articles/${encodeURIComponent(normalized)}`));
49
+ revalidatedPaths.add(toPublicReaderPath(`/articles/${encodeURIComponent(normalized)}`));
49
50
  if (editionDate) {
50
51
  revalidateTag(editionItemCacheTag(editionDate, normalized));
51
52
  revalidatedTags.add(editionItemCacheTag(editionDate, normalized));
@@ -76,8 +77,8 @@ export async function POST(request: Request) {
76
77
  revalidatedTags.add(ARCHIVE_CACHE_TAG);
77
78
  revalidateTag(ARTICLES_CACHE_TAG);
78
79
  revalidatedTags.add(ARTICLES_CACHE_TAG);
79
- revalidatePath("/archive");
80
- revalidatedPaths.add("/archive");
80
+ revalidatePath(toPublicReaderPath("/archive"));
81
+ revalidatedPaths.add(toPublicReaderPath("/archive"));
81
82
 
82
83
  for (const path of body.paths ?? []) {
83
84
  const normalized = path.trim();