@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.
- package/amplify/functions/content-actions/handler.py +8 -0
- package/amplify/plugins/newsletter/config.js +42 -3
- package/amplify/plugins/newsletter/config.ts +55 -3
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/site-backend.js +2 -0
- package/amplify/site-backend.ts +2 -0
- package/infra/site-config.js +2 -1
- package/infra/site-config.ts +7 -1
- package/lib/newsletter-api.ts +11 -3
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -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",
|
|
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
|
|
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",
|
|
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
|
|
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
|
+
}
|
package/amplify/site-backend.js
CHANGED
|
@@ -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)) {
|
package/amplify/site-backend.ts
CHANGED
|
@@ -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)) {
|
package/infra/site-config.js
CHANGED
|
@@ -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);
|
package/infra/site-config.ts
CHANGED
|
@@ -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/lib/newsletter-api.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
package/routes.manifest.json
CHANGED