@anthusai/papyrus 2.0.0-next.59 → 2.0.0-next.60

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.
@@ -8,6 +8,7 @@ from papyrus_content.edge_invalidation import distribution_ids_from_environment,
8
8
  from papyrus_content.graphql_authoring import create_authoring_client
9
9
  from papyrus_content.markus_renderer.derive import derive_body
10
10
  from papyrus_content.projection.deploy_render import deploy_render, drift_metric_line
11
+ from papyrus_content.projection.lease import ProjectionLeaseHeld
11
12
  from papyrus_content.projection.engine import project, project_all, project_item_pages
12
13
  from papyrus_content.reader_steering.task import TASK_NAME as READER_SNAPSHOT_TASK, run_reader_snapshot
13
14
  from papyrus_content.projection.runtime import (
@@ -186,6 +187,13 @@ def run_deploy_render_task(client) -> dict:
186
187
 
187
188
  def run_projection_task(task: dict) -> dict:
188
189
  """An asynchronous projection task. Raising makes Lambda retry it, then send the event to the dead letter queue."""
190
+ try:
191
+ return _run_projection_task(task)
192
+ except ProjectionLeaseHeld as held: # another projection of this publication outlasted the wait: retry later
193
+ raise ProjectionTaskFailed(str(held)) from held
194
+
195
+
196
+ def _run_projection_task(task: dict) -> dict:
189
197
  client, _claims = create_authoring_client()
190
198
  if task.get("task") == "item-pages":
191
199
  report = project_item_pages(projection_context(client), except_item_id=task.get("exceptItemId"))
@@ -11,6 +11,13 @@ const SCHEDULER_CRON = /^cron\(\S+ \S+ \S+ \S+ \S+ \S+\)$/;
11
11
  const UNSAFE_CSS = /[<>{};"\\`]|url\(|expression\(|@import/i;
12
12
  const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
13
13
  export const ITEM_URL_STYLES = ["html", "directory", "clean"];
14
+ const THEME_STYLE_KEYS = [
15
+ "surface", "muted", "fontFamily", "headerBand", "headerText", "buttonFill", "buttonText", "buttonBorder", "footerBand",
16
+ "footerText", "labelFont",
17
+ ];
18
+ const THEME_KEYS = [
19
+ "accent", "text", "background", ...THEME_STYLE_KEYS, "logoUrl", "wordmarkImageUrl", "wordmarkAlt", "wordmarkWidth", "wordmarkHeight",
20
+ ];
14
21
  const KNOWN_KEYS = new Set([
15
22
  "name", "siteId", "siteOrigin", "basePath", "computeRoleArn", "sendingDomain", "fromAddress", "fromName", "instantAlerts", "theme", "lists",
16
23
  "configurationSet", "eventTopicName", "itemUrlStyle",
@@ -97,18 +104,18 @@ function itemUrlStyle(raw) {
97
104
  return raw;
98
105
  }
99
106
  function resolveTheme(raw) {
100
- const theme = record(raw, "theme", ["accent", "text", "background", "surface", "muted", "fontFamily", "logoUrl"]);
107
+ const theme = record(raw, "theme", THEME_KEYS);
101
108
  const resolved = {
102
109
  accent: text(theme.accent, "theme.accent", 80),
103
110
  text: text(theme.text, "theme.text", 80),
104
111
  background: text(theme.background, "theme.background", 80),
105
112
  };
106
- for (const key of ["surface", "muted", "fontFamily"]) {
113
+ for (const key of THEME_STYLE_KEYS) {
107
114
  if (theme[key] !== undefined)
108
115
  resolved[key] = text(theme[key], `theme.${key}`, 200);
109
116
  }
110
117
  for (const [key, value] of Object.entries(resolved)) {
111
- if (UNSAFE_CSS.test(value))
118
+ if (typeof value === "string" && UNSAFE_CSS.test(value))
112
119
  fail(`theme.${key} contains characters that are not allowed in a style value`);
113
120
  }
114
121
  if (theme.logoUrl !== undefined) {
@@ -117,6 +124,28 @@ function resolveTheme(raw) {
117
124
  fail("theme.logoUrl must be an https URL");
118
125
  resolved.logoUrl = logoUrl;
119
126
  }
127
+ if (theme.wordmarkImageUrl !== undefined) {
128
+ const url = text(theme.wordmarkImageUrl, "theme.wordmarkImageUrl", 500);
129
+ if (!/^https:\/\/[^\s"'<>\\`]+$/.test(url))
130
+ fail("theme.wordmarkImageUrl must be an absolute https URL");
131
+ resolved.wordmarkImageUrl = url;
132
+ for (const key of ["wordmarkWidth", "wordmarkHeight"]) {
133
+ const value = theme[key];
134
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 2000) {
135
+ fail(`theme.${key} is required with theme.wordmarkImageUrl and must be a whole number of pixels from 1 to 2000`);
136
+ }
137
+ resolved[key] = value;
138
+ }
139
+ }
140
+ else if (theme.wordmarkWidth !== undefined || theme.wordmarkHeight !== undefined) {
141
+ fail("theme.wordmarkWidth and theme.wordmarkHeight need theme.wordmarkImageUrl");
142
+ }
143
+ if (theme.wordmarkAlt !== undefined) {
144
+ const alt = text(theme.wordmarkAlt, "theme.wordmarkAlt", 200);
145
+ if (/[\u0000-\u001f\u007f]/.test(alt))
146
+ fail("theme.wordmarkAlt must not contain control characters");
147
+ resolved.wordmarkAlt = alt;
148
+ }
120
149
  return resolved;
121
150
  }
122
151
  function resolveRule(raw, label) {
@@ -24,8 +24,30 @@ export type NewsletterTheme = {
24
24
  muted?: string;
25
25
  fontFamily?: string;
26
26
  logoUrl?: string;
27
+ /** Branding, all optional: a plain theme keeps the plain layout. Colours are CSS values; the border is 1.5px when set. */
28
+ headerBand?: string;
29
+ headerText?: string;
30
+ buttonFill?: string;
31
+ buttonText?: string;
32
+ buttonBorder?: string;
33
+ footerBand?: string;
34
+ footerText?: string;
35
+ /** An absolute https image, drawn with alt text and explicit pixel size (web fonts do not load in email). */
36
+ wordmarkImageUrl?: string;
37
+ wordmarkAlt?: string;
38
+ wordmarkWidth?: number;
39
+ wordmarkHeight?: number;
40
+ labelFont?: string;
27
41
  };
28
42
 
43
+ const THEME_STYLE_KEYS = [
44
+ "surface", "muted", "fontFamily", "headerBand", "headerText", "buttonFill", "buttonText", "buttonBorder", "footerBand",
45
+ "footerText", "labelFont",
46
+ ] as const;
47
+ const THEME_KEYS = [
48
+ "accent", "text", "background", ...THEME_STYLE_KEYS, "logoUrl", "wordmarkImageUrl", "wordmarkAlt", "wordmarkWidth", "wordmarkHeight",
49
+ ];
50
+
29
51
  export type NewsletterRule = { sections?: string[]; tags?: string[]; types?: string[] };
30
52
 
31
53
  type ListBase = { id: string; name: string; description: string };
@@ -152,23 +174,42 @@ function itemUrlStyle(raw: unknown): ItemUrlStyle {
152
174
  }
153
175
 
154
176
  function resolveTheme(raw: unknown): NewsletterTheme {
155
- const theme = record(raw, "theme", ["accent", "text", "background", "surface", "muted", "fontFamily", "logoUrl"]);
177
+ const theme = record(raw, "theme", THEME_KEYS);
156
178
  const resolved: NewsletterTheme = {
157
179
  accent: text(theme.accent, "theme.accent", 80),
158
180
  text: text(theme.text, "theme.text", 80),
159
181
  background: text(theme.background, "theme.background", 80),
160
182
  };
161
- for (const key of ["surface", "muted", "fontFamily"] as const) {
183
+ for (const key of THEME_STYLE_KEYS) {
162
184
  if (theme[key] !== undefined) resolved[key] = text(theme[key], `theme.${key}`, 200);
163
185
  }
164
186
  for (const [key, value] of Object.entries(resolved)) {
165
- if (UNSAFE_CSS.test(value)) fail(`theme.${key} contains characters that are not allowed in a style value`);
187
+ if (typeof value === "string" && UNSAFE_CSS.test(value)) fail(`theme.${key} contains characters that are not allowed in a style value`);
166
188
  }
167
189
  if (theme.logoUrl !== undefined) {
168
190
  const logoUrl = text(theme.logoUrl, "theme.logoUrl", 500);
169
191
  if (!/^https:\/\//.test(logoUrl)) fail("theme.logoUrl must be an https URL");
170
192
  resolved.logoUrl = logoUrl;
171
193
  }
194
+ if (theme.wordmarkImageUrl !== undefined) {
195
+ const url = text(theme.wordmarkImageUrl, "theme.wordmarkImageUrl", 500);
196
+ if (!/^https:\/\/[^\s"'<>\\`]+$/.test(url)) fail("theme.wordmarkImageUrl must be an absolute https URL");
197
+ resolved.wordmarkImageUrl = url;
198
+ for (const key of ["wordmarkWidth", "wordmarkHeight"] as const) {
199
+ const value = theme[key];
200
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 2000) {
201
+ fail(`theme.${key} is required with theme.wordmarkImageUrl and must be a whole number of pixels from 1 to 2000`);
202
+ }
203
+ resolved[key] = value;
204
+ }
205
+ } else if (theme.wordmarkWidth !== undefined || theme.wordmarkHeight !== undefined) {
206
+ fail("theme.wordmarkWidth and theme.wordmarkHeight need theme.wordmarkImageUrl");
207
+ }
208
+ if (theme.wordmarkAlt !== undefined) {
209
+ const alt = text(theme.wordmarkAlt, "theme.wordmarkAlt", 200);
210
+ if (/[\u0000-\u001f\u007f]/.test(alt)) fail("theme.wordmarkAlt must not contain control characters");
211
+ resolved.wordmarkAlt = alt;
212
+ }
172
213
  return resolved;
173
214
  }
174
215
 
@@ -0,0 +1,30 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ /** The shared lock table's name (created by the cloud knowledge run stack). The Lambda finds it through this SSM parameter. */
4
+ export const PROJECTION_LOCK_TABLE_PARAMETER = "/papyrus/projection-lock/table-name";
5
+ export const PROJECTION_LOCK_TABLE_NAME = "papyrus-projection-locks";
6
+ export const PROJECTION_LOCK_TABLE_ACTIONS = ["dynamodb:PutItem", "dynamodb:DeleteItem", "dynamodb:UpdateItem", "dynamodb:GetItem"];
7
+ export function projectionLeaseParameterArn(region, account) {
8
+ return `arn:aws:ssm:${region}:${account}:parameter${PROJECTION_LOCK_TABLE_PARAMETER}`;
9
+ }
10
+ export function projectionLeaseTableArn(region, account) {
11
+ return `arn:aws:dynamodb:${region}:${account}:table/${PROJECTION_LOCK_TABLE_NAME}*`;
12
+ }
13
+ /**
14
+ * Let a projecting Lambda take and release the per-publication projection lease: read the table-name
15
+ * parameter and write items in the lock table, nothing broader. The table itself is not created here;
16
+ * a deployment without it keeps projecting without a lease.
17
+ */
18
+ export function addProjectionLeaseAccess(lambda) {
19
+ const stack = Stack.of(lambda);
20
+ lambda.addToRolePolicy(new PolicyStatement({
21
+ effect: Effect.ALLOW,
22
+ actions: ["ssm:GetParameter"],
23
+ resources: [projectionLeaseParameterArn(stack.region, stack.account)],
24
+ }));
25
+ lambda.addToRolePolicy(new PolicyStatement({
26
+ effect: Effect.ALLOW,
27
+ actions: PROJECTION_LOCK_TABLE_ACTIONS,
28
+ resources: [projectionLeaseTableArn(stack.region, stack.account)],
29
+ }));
30
+ }
@@ -0,0 +1,39 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ import type { IFunction } from "aws-cdk-lib/aws-lambda";
4
+
5
+ /** The shared lock table's name (created by the cloud knowledge run stack). The Lambda finds it through this SSM parameter. */
6
+ export const PROJECTION_LOCK_TABLE_PARAMETER = "/papyrus/projection-lock/table-name";
7
+ export const PROJECTION_LOCK_TABLE_NAME = "papyrus-projection-locks";
8
+ export const PROJECTION_LOCK_TABLE_ACTIONS = ["dynamodb:PutItem", "dynamodb:DeleteItem", "dynamodb:UpdateItem", "dynamodb:GetItem"];
9
+
10
+ export function projectionLeaseParameterArn(region: string, account: string): string {
11
+ return `arn:aws:ssm:${region}:${account}:parameter${PROJECTION_LOCK_TABLE_PARAMETER}`;
12
+ }
13
+
14
+ export function projectionLeaseTableArn(region: string, account: string): string {
15
+ return `arn:aws:dynamodb:${region}:${account}:table/${PROJECTION_LOCK_TABLE_NAME}*`;
16
+ }
17
+
18
+ /**
19
+ * Let a projecting Lambda take and release the per-publication projection lease: read the table-name
20
+ * parameter and write items in the lock table, nothing broader. The table itself is not created here;
21
+ * a deployment without it keeps projecting without a lease.
22
+ */
23
+ export function addProjectionLeaseAccess(lambda: IFunction): void {
24
+ const stack = Stack.of(lambda);
25
+ lambda.addToRolePolicy(
26
+ new PolicyStatement({
27
+ effect: Effect.ALLOW,
28
+ actions: ["ssm:GetParameter"],
29
+ resources: [projectionLeaseParameterArn(stack.region, stack.account)],
30
+ }),
31
+ );
32
+ lambda.addToRolePolicy(
33
+ new PolicyStatement({
34
+ effect: Effect.ALLOW,
35
+ actions: PROJECTION_LOCK_TABLE_ACTIONS,
36
+ resources: [projectionLeaseTableArn(stack.region, stack.account)],
37
+ }),
38
+ );
39
+ }
@@ -11,6 +11,7 @@ import { resolveSitePublication } from "./publication/default-publication.js";
11
11
  import { REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS, addReprojectOnDeploy, reprojectDeployToken } from "./publication/reproject-on-deploy.js";
12
12
  import { addProjectionRetries, addS3ProjectionPublication } from "./publication/s3-projection.js";
13
13
  import { planRenditions } from "./publication/renditions-settings.js";
14
+ import { addProjectionLeaseAccess } from "./publication/projection-lease.js";
14
15
  import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings.js";
15
16
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
16
17
  import { assertValidPublicationConfig } from "./site-backend-config.js";
@@ -360,6 +361,7 @@ export function defineSiteBackend(papyrusSite) {
360
361
  }
361
362
  if (s3ProjectionPublication !== undefined) {
362
363
  addProjectionRetries(contentActionsLambda, s3ProjectionPublication.deadLetterQueue);
364
+ addProjectionLeaseAccess(contentActionsLambda);
363
365
  }
364
366
  const publicationWiring = contentActionsPublicationWiring(s3ProjectionPublication);
365
367
  for (const [name, value] of Object.entries(publicationWiring.environment)) {
@@ -14,6 +14,7 @@ import { resolveSitePublication } from "./publication/default-publication";
14
14
  import { REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS, addReprojectOnDeploy, reprojectDeployToken } from "./publication/reproject-on-deploy";
15
15
  import { addProjectionRetries, addS3ProjectionPublication, type S3ProjectionPublication } from "./publication/s3-projection";
16
16
  import { planRenditions } from "./publication/renditions-settings";
17
+ import { addProjectionLeaseAccess } from "./publication/projection-lease";
17
18
  import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings";
18
19
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
19
20
  import { assertValidPublicationConfig } from "./site-backend-config";
@@ -456,6 +457,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
456
457
  }
457
458
  if (s3ProjectionPublication !== undefined) {
458
459
  addProjectionRetries(contentActionsLambda, s3ProjectionPublication.deadLetterQueue);
460
+ addProjectionLeaseAccess(contentActionsLambda);
459
461
  }
460
462
  const publicationWiring = contentActionsPublicationWiring(s3ProjectionPublication);
461
463
  for (const [name, value] of Object.entries(publicationWiring.environment)) {
@@ -350,7 +350,7 @@ function parseAws(value) {
350
350
  }
351
351
  export function parseSiteConfig(raw) {
352
352
  const record = requireObject(raw, "site");
353
- rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"], "site");
353
+ rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
354
354
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
355
355
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
356
356
  const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
@@ -465,6 +465,7 @@ export function parseSiteConfig(raw) {
465
465
  storagePreviewPrefix,
466
466
  autoDeploy: autoDeploy,
467
467
  github,
468
+ ...(record.knowledgeRun !== undefined ? { knowledgeRun: record.knowledgeRun } : {}),
468
469
  };
469
470
  if (hasCmsApp(config))
470
471
  validateOauthRedirectUrls(config);
@@ -78,6 +78,11 @@ export type AmplifyAppShellSiteConfig = {
78
78
  };
79
79
  papyrusVersion: string;
80
80
  storagePreviewPrefix?: string;
81
+ /**
82
+ * The scheduled cloud knowledge run for this publication. The app-shell stack ignores it; it is validated by
83
+ * infra/scheduled-jobs/lib/knowledge-run-config.ts, which owns the schema.
84
+ */
85
+ knowledgeRun?: unknown;
81
86
  /**
82
87
  * Merge-to-main deploy from GitHub Actions through OIDC. When true (the default) the stack creates the
83
88
  * `<siteId>-github-deploy` role and turns off Amplify's own auto-build on the production branches, so the
@@ -485,7 +490,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
485
490
  const record = requireObject(raw, "site");
486
491
  rejectUnknownKeys(
487
492
  record,
488
- ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"],
493
+ ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"],
489
494
  "site",
490
495
  );
491
496
 
@@ -600,6 +605,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
600
605
  storagePreviewPrefix,
601
606
  autoDeploy: autoDeploy as boolean,
602
607
  github,
608
+ ...(record.knowledgeRun !== undefined ? { knowledgeRun: record.knowledgeRun } : {}),
603
609
  };
604
610
 
605
611
  if (hasCmsApp(config)) validateOauthRedirectUrls(config);
package/package.json CHANGED
@@ -433,7 +433,7 @@
433
433
  "optional": true
434
434
  }
435
435
  },
436
- "version": "2.0.0-next.59",
436
+ "version": "2.0.0-next.60",
437
437
  "dependencies": {
438
438
  "@aws-amplify/adapter-nextjs": "^1.7.3",
439
439
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.0.0-next.59",
2
+ "version": "2.0.0-next.60",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",