@anthusai/papyrus 5.27.0 → 5.29.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.
@@ -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,14 @@ 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
+ "footerWordmarkWidth",
21
+ ];
14
22
  const KNOWN_KEYS = new Set([
15
23
  "name", "siteId", "siteOrigin", "basePath", "computeRoleArn", "sendingDomain", "fromAddress", "fromName", "instantAlerts", "theme", "lists",
16
24
  "configurationSet", "eventTopicName", "itemUrlStyle",
@@ -97,18 +105,18 @@ function itemUrlStyle(raw) {
97
105
  return raw;
98
106
  }
99
107
  function resolveTheme(raw) {
100
- const theme = record(raw, "theme", ["accent", "text", "background", "surface", "muted", "fontFamily", "logoUrl"]);
108
+ const theme = record(raw, "theme", THEME_KEYS);
101
109
  const resolved = {
102
110
  accent: text(theme.accent, "theme.accent", 80),
103
111
  text: text(theme.text, "theme.text", 80),
104
112
  background: text(theme.background, "theme.background", 80),
105
113
  };
106
- for (const key of ["surface", "muted", "fontFamily"]) {
114
+ for (const key of THEME_STYLE_KEYS) {
107
115
  if (theme[key] !== undefined)
108
116
  resolved[key] = text(theme[key], `theme.${key}`, 200);
109
117
  }
110
118
  for (const [key, value] of Object.entries(resolved)) {
111
- if (UNSAFE_CSS.test(value))
119
+ if (typeof value === "string" && UNSAFE_CSS.test(value))
112
120
  fail(`theme.${key} contains characters that are not allowed in a style value`);
113
121
  }
114
122
  if (theme.logoUrl !== undefined) {
@@ -117,6 +125,37 @@ function resolveTheme(raw) {
117
125
  fail("theme.logoUrl must be an https URL");
118
126
  resolved.logoUrl = logoUrl;
119
127
  }
128
+ if (theme.wordmarkImageUrl !== undefined) {
129
+ const url = text(theme.wordmarkImageUrl, "theme.wordmarkImageUrl", 500);
130
+ if (!/^https:\/\/[^\s"'<>\\`]+$/.test(url))
131
+ fail("theme.wordmarkImageUrl must be an absolute https URL");
132
+ resolved.wordmarkImageUrl = url;
133
+ for (const key of ["wordmarkWidth", "wordmarkHeight"]) {
134
+ const value = theme[key];
135
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 2000) {
136
+ fail(`theme.${key} is required with theme.wordmarkImageUrl and must be a whole number of pixels from 1 to 2000`);
137
+ }
138
+ resolved[key] = value;
139
+ }
140
+ }
141
+ else if (theme.wordmarkWidth !== undefined || theme.wordmarkHeight !== undefined) {
142
+ fail("theme.wordmarkWidth and theme.wordmarkHeight need theme.wordmarkImageUrl");
143
+ }
144
+ if (theme.footerWordmarkWidth !== undefined) {
145
+ if (theme.wordmarkImageUrl === undefined)
146
+ fail("theme.footerWordmarkWidth needs theme.wordmarkImageUrl");
147
+ const width = theme.footerWordmarkWidth;
148
+ if (typeof width !== "number" || !Number.isInteger(width) || width < 1 || width > 400) {
149
+ fail("theme.footerWordmarkWidth must be a whole number of pixels from 1 to 400");
150
+ }
151
+ resolved.footerWordmarkWidth = width;
152
+ }
153
+ if (theme.wordmarkAlt !== undefined) {
154
+ const alt = text(theme.wordmarkAlt, "theme.wordmarkAlt", 200);
155
+ if (/[\u0000-\u001f\u007f]/.test(alt))
156
+ fail("theme.wordmarkAlt must not contain control characters");
157
+ resolved.wordmarkAlt = alt;
158
+ }
120
159
  return resolved;
121
160
  }
122
161
  function resolveRule(raw, label) {
@@ -24,8 +24,33 @@ 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
+ /** Draws the footer's wordmark at this width in pixels (1 to 400), smaller than the header's; the height keeps the aspect ratio. */
41
+ footerWordmarkWidth?: number;
42
+ labelFont?: string;
27
43
  };
28
44
 
45
+ const THEME_STYLE_KEYS = [
46
+ "surface", "muted", "fontFamily", "headerBand", "headerText", "buttonFill", "buttonText", "buttonBorder", "footerBand",
47
+ "footerText", "labelFont",
48
+ ] as const;
49
+ const THEME_KEYS = [
50
+ "accent", "text", "background", ...THEME_STYLE_KEYS, "logoUrl", "wordmarkImageUrl", "wordmarkAlt", "wordmarkWidth", "wordmarkHeight",
51
+ "footerWordmarkWidth",
52
+ ];
53
+
29
54
  export type NewsletterRule = { sections?: string[]; tags?: string[]; types?: string[] };
30
55
 
31
56
  type ListBase = { id: string; name: string; description: string };
@@ -152,23 +177,50 @@ function itemUrlStyle(raw: unknown): ItemUrlStyle {
152
177
  }
153
178
 
154
179
  function resolveTheme(raw: unknown): NewsletterTheme {
155
- const theme = record(raw, "theme", ["accent", "text", "background", "surface", "muted", "fontFamily", "logoUrl"]);
180
+ const theme = record(raw, "theme", THEME_KEYS);
156
181
  const resolved: NewsletterTheme = {
157
182
  accent: text(theme.accent, "theme.accent", 80),
158
183
  text: text(theme.text, "theme.text", 80),
159
184
  background: text(theme.background, "theme.background", 80),
160
185
  };
161
- for (const key of ["surface", "muted", "fontFamily"] as const) {
186
+ for (const key of THEME_STYLE_KEYS) {
162
187
  if (theme[key] !== undefined) resolved[key] = text(theme[key], `theme.${key}`, 200);
163
188
  }
164
189
  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`);
190
+ if (typeof value === "string" && UNSAFE_CSS.test(value)) fail(`theme.${key} contains characters that are not allowed in a style value`);
166
191
  }
167
192
  if (theme.logoUrl !== undefined) {
168
193
  const logoUrl = text(theme.logoUrl, "theme.logoUrl", 500);
169
194
  if (!/^https:\/\//.test(logoUrl)) fail("theme.logoUrl must be an https URL");
170
195
  resolved.logoUrl = logoUrl;
171
196
  }
197
+ if (theme.wordmarkImageUrl !== undefined) {
198
+ const url = text(theme.wordmarkImageUrl, "theme.wordmarkImageUrl", 500);
199
+ if (!/^https:\/\/[^\s"'<>\\`]+$/.test(url)) fail("theme.wordmarkImageUrl must be an absolute https URL");
200
+ resolved.wordmarkImageUrl = url;
201
+ for (const key of ["wordmarkWidth", "wordmarkHeight"] as const) {
202
+ const value = theme[key];
203
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 2000) {
204
+ fail(`theme.${key} is required with theme.wordmarkImageUrl and must be a whole number of pixels from 1 to 2000`);
205
+ }
206
+ resolved[key] = value;
207
+ }
208
+ } else if (theme.wordmarkWidth !== undefined || theme.wordmarkHeight !== undefined) {
209
+ fail("theme.wordmarkWidth and theme.wordmarkHeight need theme.wordmarkImageUrl");
210
+ }
211
+ if (theme.footerWordmarkWidth !== undefined) {
212
+ if (theme.wordmarkImageUrl === undefined) fail("theme.footerWordmarkWidth needs theme.wordmarkImageUrl");
213
+ const width = theme.footerWordmarkWidth;
214
+ if (typeof width !== "number" || !Number.isInteger(width) || width < 1 || width > 400) {
215
+ fail("theme.footerWordmarkWidth must be a whole number of pixels from 1 to 400");
216
+ }
217
+ resolved.footerWordmarkWidth = width;
218
+ }
219
+ if (theme.wordmarkAlt !== undefined) {
220
+ const alt = text(theme.wordmarkAlt, "theme.wordmarkAlt", 200);
221
+ if (/[\u0000-\u001f\u007f]/.test(alt)) fail("theme.wordmarkAlt must not contain control characters");
222
+ resolved.wordmarkAlt = alt;
223
+ }
172
224
  return resolved;
173
225
  }
174
226
 
@@ -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);
@@ -238,6 +238,14 @@ function call(options: NewsletterApiOptions, functionName: string, event: Newsle
238
238
  return (options.invoke ?? invokeNewsletterLambda)(functionName, event);
239
239
  }
240
240
 
241
+ /**
242
+ * Next answers HEAD by running the route's GET export, but leaves `request.method` as "HEAD". A check for `=== "GET"` therefore
243
+ * sends a mail scanner's HEAD down the POST path. Every read-only branch asks this instead, so HEAD redirects exactly like GET.
244
+ */
245
+ function isReadRequest(request: Pick<NextRequest, "method">): boolean {
246
+ return request.method === "GET" || request.method === "HEAD";
247
+ }
248
+
241
249
  function textResponse(body: string, status: number): NextResponse {
242
250
  return new NextResponse(body, { status, headers: { ...NO_STORE, "content-type": "text/plain; charset=utf-8" } });
243
251
  }
@@ -266,7 +274,7 @@ export async function handleNewsletterConfirm(request: NextRequest, options: New
266
274
  if (!plugin) return notFoundPage();
267
275
  const to = redirector(request, environment, plugin);
268
276
  if ("response" in to) return to.response;
269
- if (request.method === "GET") {
277
+ if (isReadRequest(request)) {
270
278
  const token = manageTokenFrom(request.nextUrl.searchParams.get("t"));
271
279
  return token ? to.go("confirm", { token }, 302) : to.go("expired", {}, 302);
272
280
  }
@@ -304,7 +312,7 @@ export async function handleNewsletterUnsubscribe(request: NextRequest, options:
304
312
  const to = redirector(request, environment, plugin);
305
313
  if ("response" in to) return to.response;
306
314
  const query = request.nextUrl.searchParams;
307
- if (request.method === "GET") {
315
+ if (isReadRequest(request)) {
308
316
  const token = manageTokenFrom(query.get("t"));
309
317
  const listId = listParameter(query.get("l"));
310
318
  return token ? to.go("unsubscribe", { token, ...(listId ? { list: listId } : {}) }, 302) : to.go("expired", {}, 302);
@@ -344,7 +352,7 @@ export async function handleNewsletterPreferences(request: NextRequest, options:
344
352
  if (!plugin) return notFoundPage();
345
353
  const to = redirector(request, environment, plugin);
346
354
  if ("response" in to) return to.response;
347
- if (request.method === "GET") {
355
+ if (isReadRequest(request)) {
348
356
  const token = manageTokenFrom(request.nextUrl.searchParams.get("t"));
349
357
  return token ? to.go("preferences", { token }, 302) : to.go("expired", {}, 302);
350
358
  }
package/package.json CHANGED
@@ -433,7 +433,7 @@
433
433
  "optional": true
434
434
  }
435
435
  },
436
- "version": "5.27.0",
436
+ "version": "5.29.0",
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": "5.27.0",
2
+ "version": "5.29.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",