@anthusai/papyrus 2.0.0-next.4 → 2.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.
- package/amplify/data/resource.js +23 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +2030 -1967
- package/amplify/data/schema.ts +73 -2
- package/amplify/functions/assignment-action/data-access.js +88 -0
- package/amplify/functions/assignment-action/data-access.ts +87 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +36 -0
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/editorial-checks/handler.py +14 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/functions/editorial-checks/resource.js +43 -0
- package/amplify/functions/editorial-checks/resource.ts +60 -0
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/functions/editorial-checks/schema.ts +123 -0
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +9 -3
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/reader-steering/config.js +110 -0
- package/amplify/plugins/reader-steering/config.ts +153 -0
- package/amplify/plugins/reader-steering/function/handler.py +544 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +103 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +122 -0
- package/amplify/plugins/reader-steering/schema.js +40 -0
- package/amplify/plugins/reader-steering/schema.ts +42 -0
- package/amplify/plugins/registry.js +2 -0
- package/amplify/plugins/registry.ts +2 -0
- package/amplify/plugins/types.ts +3 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +17 -0
- package/amplify/site-backend.js +36 -1
- package/amplify/site-backend.ts +44 -1
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +362 -0
- package/components/editorial-finding-card.tsx +162 -0
- package/components/editorial-findings-panel.tsx +91 -0
- package/components/editorial-preview-popover.tsx +103 -0
- package/components/markus-body-editor.tsx +213 -0
- package/components/markus-ir-preview.tsx +147 -6
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsroom-article-editor.tsx +136 -11
- package/components/newsroom-board-view.tsx +6 -7
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-shell.tsx +7 -3
- package/components/newsroom-organizer.css +74 -61
- package/components/newsroom-references-view.tsx +14 -14
- package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
- package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
- package/components/newsroom-theme-fonts/night-desk.css +10 -0
- package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
- package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
- package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
- package/components/newsroom-theme.css +39 -0
- package/components/newsroom-theme.tsx +33 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +27 -2
- package/components/use-editorial-checks.ts +133 -0
- package/infra/amplify-app-shell.js +90 -16
- package/infra/amplify-app-shell.ts +102 -18
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +85 -6
- package/infra/site-config.ts +123 -6
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/editorial-checks-backend.ts +16 -0
- package/lib/editorial-checks-client.ts +212 -0
- package/lib/editorial-checks-demo.ts +147 -0
- package/lib/editorial-checks-settings.ts +27 -0
- package/lib/editorial-checks.ts +173 -0
- package/lib/editorial-preview.ts +228 -0
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/newsroom-board.ts +27 -2
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +54 -13
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +143 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/relevance-status.ts +2 -2
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +23 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +56 -4
- package/routes.manifest.json +89 -1
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
- package/components/newsroom-newsprint-board.css +0 -7
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Construct } from "constructs";
|
|
2
|
+
import { ArnFormat, CustomResource, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
3
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
4
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
5
|
+
import * as customResources from "aws-cdk-lib/custom-resources";
|
|
6
|
+
import { SECURE_STRING_PARAMETER_HANDLER_SOURCE } from "./secure-string-parameter-handler";
|
|
7
|
+
|
|
8
|
+
export interface SecureStringParameterProps {
|
|
9
|
+
/** Absolute parameter name, e.g. `/papyrus/<siteId>/prospectus-session-sealing`. */
|
|
10
|
+
parameterName: string;
|
|
11
|
+
description: string;
|
|
12
|
+
/** Length of the generated value. Default 64. */
|
|
13
|
+
length?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One Lambda and role per stack, shared by every SecureStringParameter in it. */
|
|
17
|
+
class SecureStringParameterProvider extends Construct {
|
|
18
|
+
static readonly ID = "SecureStringParameterProvider";
|
|
19
|
+
public readonly role: iam.Role;
|
|
20
|
+
public readonly provider: customResources.Provider;
|
|
21
|
+
|
|
22
|
+
static of(stack: Stack): SecureStringParameterProvider {
|
|
23
|
+
return (stack.node.tryFindChild(SecureStringParameterProvider.ID) as SecureStringParameterProvider | undefined)
|
|
24
|
+
?? new SecureStringParameterProvider(stack);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private constructor(stack: Stack) {
|
|
28
|
+
super(stack, SecureStringParameterProvider.ID);
|
|
29
|
+
this.role = new iam.Role(this, "Role", {
|
|
30
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
31
|
+
description: "Creates generated SecureString parameters: PutParameter per named parameter, never overwriting",
|
|
32
|
+
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")],
|
|
33
|
+
});
|
|
34
|
+
const onEvent = new lambda.Function(this, "OnEvent", {
|
|
35
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
36
|
+
handler: "index.handler",
|
|
37
|
+
code: lambda.Code.fromInline(SECURE_STRING_PARAMETER_HANDLER_SOURCE),
|
|
38
|
+
role: this.role,
|
|
39
|
+
timeout: Duration.seconds(30),
|
|
40
|
+
description: "Creates a generated SecureString parameter once; never returns or logs the value",
|
|
41
|
+
});
|
|
42
|
+
this.provider = new customResources.Provider(this, "Provider", { onEventHandler: onEvent });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A generated secret in SSM Parameter Store as a standard SecureString (free,
|
|
48
|
+
* AWS-managed `aws/ssm` key), instead of a Secrets Manager secret ($0.40/month).
|
|
49
|
+
*
|
|
50
|
+
* CloudFormation cannot create a SecureString, so a custom resource Lambda puts
|
|
51
|
+
* it. The Lambda generates a random value at create time and never returns or
|
|
52
|
+
* logs it, so the value is in no template, output or stack event. It is
|
|
53
|
+
* create-only: an update never regenerates it and never overwrites a parameter
|
|
54
|
+
* that already exists (a hand-made one is adopted as it is), and delete retains
|
|
55
|
+
* it, so a stack teardown cannot sign anyone out. Rotate it with
|
|
56
|
+
* `aws ssm put-parameter --overwrite`. Readers get `ssm:GetParameter` on
|
|
57
|
+
* `parameterArn` only; decrypting with the AWS-managed key needs no KMS grant.
|
|
58
|
+
*/
|
|
59
|
+
export class SecureStringParameter extends Construct {
|
|
60
|
+
public readonly parameterName: string;
|
|
61
|
+
public readonly parameterArn: string;
|
|
62
|
+
|
|
63
|
+
constructor(scope: Construct, id: string, props: SecureStringParameterProps) {
|
|
64
|
+
super(scope, id);
|
|
65
|
+
if (!props.parameterName.startsWith("/")) throw new Error(`SSM parameter name must be absolute: ${props.parameterName}`);
|
|
66
|
+
const length = props.length ?? 64;
|
|
67
|
+
if (!Number.isInteger(length) || length < 32) throw new Error("A generated secret must be at least 32 characters.");
|
|
68
|
+
const stack = Stack.of(this);
|
|
69
|
+
this.parameterName = props.parameterName;
|
|
70
|
+
this.parameterArn = stack.formatArn({
|
|
71
|
+
service: "ssm",
|
|
72
|
+
resource: "parameter",
|
|
73
|
+
resourceName: props.parameterName.slice(1),
|
|
74
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const shared = SecureStringParameterProvider.of(stack);
|
|
78
|
+
shared.role.addToPolicy(new iam.PolicyStatement({
|
|
79
|
+
sid: `Put${id.replace(/[^A-Za-z0-9]/g, "")}`,
|
|
80
|
+
actions: ["ssm:PutParameter"],
|
|
81
|
+
resources: [this.parameterArn],
|
|
82
|
+
}));
|
|
83
|
+
const resource = new CustomResource(this, "Resource", {
|
|
84
|
+
serviceToken: shared.provider.serviceToken,
|
|
85
|
+
resourceType: "Custom::SecureStringParameter",
|
|
86
|
+
properties: { ParameterName: props.parameterName, Description: props.description, Length: String(length) },
|
|
87
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
88
|
+
});
|
|
89
|
+
resource.node.addDependency(shared.role);
|
|
90
|
+
}
|
|
91
|
+
}
|
package/infra/site-config.js
CHANGED
|
@@ -2,12 +2,29 @@ export const READER_REDIRECT_STATUSES = ["301", "302", "200", "404", "404-200"];
|
|
|
2
2
|
export function hasCmsApp(config) {
|
|
3
3
|
return config.cms !== undefined;
|
|
4
4
|
}
|
|
5
|
+
export function resolveGithubRepository(config) {
|
|
6
|
+
if (config.github)
|
|
7
|
+
return { owner: config.github.owner, repo: config.github.repo };
|
|
8
|
+
const [owner, repo] = config.repository.replace("https://github.com/", "").split("/");
|
|
9
|
+
return { owner, repo };
|
|
10
|
+
}
|
|
11
|
+
/** The OIDC `sub` values a GitHub Actions run on `ref` of the site's repository can present. */
|
|
12
|
+
export function githubOidcSubjects(config, ref) {
|
|
13
|
+
const { owner, repo } = resolveGithubRepository(config);
|
|
14
|
+
const subjects = [`repo:${owner}/${repo}:ref:refs/heads/${ref}`];
|
|
15
|
+
const { ownerId, repoId } = config.github ?? {};
|
|
16
|
+
if (ownerId !== undefined && repoId !== undefined) {
|
|
17
|
+
subjects.push(`repo:${owner}@${ownerId}/${repo}@${repoId}:ref:refs/heads/${ref}`);
|
|
18
|
+
}
|
|
19
|
+
return subjects;
|
|
20
|
+
}
|
|
5
21
|
export const DEFAULT_CI_BRANCHES = ["main", "staging"];
|
|
6
22
|
const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
7
23
|
const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
8
24
|
const SECRETS_MANAGER_SECRET_ARN_PATTERN = /^arn:aws:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+$/;
|
|
9
25
|
const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
|
|
10
26
|
const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
|
|
27
|
+
const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
|
|
11
28
|
const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
12
29
|
const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
|
|
13
30
|
const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
|
|
@@ -18,7 +35,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
|
18
35
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
19
36
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
20
37
|
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
21
|
-
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "
|
|
38
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
|
|
22
39
|
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
23
40
|
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
24
41
|
"GET/v1/identities/me",
|
|
@@ -43,6 +60,12 @@ function originOfRedirectUrl(value) {
|
|
|
43
60
|
return value;
|
|
44
61
|
}
|
|
45
62
|
}
|
|
63
|
+
export function resolveProspectusSessionSealingParameterName(config) {
|
|
64
|
+
return `/papyrus/${config.siteId}/prospectus-session-sealing`;
|
|
65
|
+
}
|
|
66
|
+
export function resolveReaderPollSecretParameterName(config) {
|
|
67
|
+
return `/papyrus/${config.siteId}/reader-poll-secret`;
|
|
68
|
+
}
|
|
46
69
|
export function resolveRevalidateSecretParameterName(config) {
|
|
47
70
|
return `/papyrus/${config.siteId}/revalidate-secret`;
|
|
48
71
|
}
|
|
@@ -168,6 +191,9 @@ export function resolveCmsHostName(config) {
|
|
|
168
191
|
return undefined;
|
|
169
192
|
return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
|
|
170
193
|
}
|
|
194
|
+
export function resolveSiteOrigin(config) {
|
|
195
|
+
return config.cms.siteOrigin;
|
|
196
|
+
}
|
|
171
197
|
export function resolveStagingDomainName(config) {
|
|
172
198
|
if (!isStagingEnabled(config))
|
|
173
199
|
return undefined;
|
|
@@ -199,7 +225,7 @@ function parseStagingPreview(value) {
|
|
|
199
225
|
}
|
|
200
226
|
function parseCms(value) {
|
|
201
227
|
const cmsRecord = requireObject(value, "cms");
|
|
202
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
|
|
228
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "readerRetirementPoll", "deployStackInBuild"], "cms");
|
|
203
229
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
204
230
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
205
231
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -210,6 +236,12 @@ function parseCms(value) {
|
|
|
210
236
|
if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
|
|
211
237
|
fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
|
|
212
238
|
}
|
|
239
|
+
if (cmsRecord.readerRetirementPoll !== undefined && typeof cmsRecord.readerRetirementPoll !== "boolean") {
|
|
240
|
+
fail("cms.readerRetirementPoll", `must be a boolean, got ${JSON.stringify(cmsRecord.readerRetirementPoll)}`);
|
|
241
|
+
}
|
|
242
|
+
if (cmsRecord.readerRetirementPoll === true && cmsRecord.prospectusAccountsApiStageArn === undefined) {
|
|
243
|
+
fail("cms.readerRetirementPoll", "requires cms.prospectusAccountsApiStageArn: the poll calls Prospectus as the site");
|
|
244
|
+
}
|
|
213
245
|
if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
|
|
214
246
|
fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
|
|
215
247
|
}
|
|
@@ -217,6 +249,7 @@ function parseCms(value) {
|
|
|
217
249
|
appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
|
|
218
250
|
domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
|
|
219
251
|
domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
252
|
+
siteOrigin: optionalString(cmsRecord.siteOrigin, "cms.siteOrigin", SITE_ORIGIN_PATTERN, "https origin with no path, query or trailing slash, such as https://shipped.anth.us"),
|
|
220
253
|
staging: cmsRecord.staging,
|
|
221
254
|
cognitoDomainPrefix: requireString(cmsRecord.cognitoDomainPrefix, "cms.cognitoDomainPrefix", COGNITO_DOMAIN_PREFIX_PATTERN, "lowercase letters, digits and hyphens; globally unique per region"),
|
|
222
255
|
applyCognitoDomainPrefix: cmsRecord.applyCognitoDomainPrefix,
|
|
@@ -226,6 +259,7 @@ function parseCms(value) {
|
|
|
226
259
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
227
260
|
redirects: parseRedirects(cmsRecord.redirects),
|
|
228
261
|
deployStackInBuild: cmsRecord.deployStackInBuild,
|
|
262
|
+
readerRetirementPoll: cmsRecord.readerRetirementPoll,
|
|
229
263
|
stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
|
|
230
264
|
prospectusAccountsApiStageArn: optionalString(cmsRecord.prospectusAccountsApiStageArn, "cms.prospectusAccountsApiStageArn", PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN, "arn:aws:execute-api:<region>:<account>:<api id>/api"),
|
|
231
265
|
};
|
|
@@ -235,9 +269,12 @@ function parseCms(value) {
|
|
|
235
269
|
}
|
|
236
270
|
for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
|
|
237
271
|
if (key in cms.environment) {
|
|
238
|
-
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
|
|
272
|
+
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn and the public site origin comes from the site host");
|
|
239
273
|
}
|
|
240
274
|
}
|
|
275
|
+
if (cms.siteOrigin && cms.domainName) {
|
|
276
|
+
fail("cms.siteOrigin", "is for sites whose host is served by their own edge; remove it when cms.domainName is set");
|
|
277
|
+
}
|
|
241
278
|
if (cms.staging === false && cms.stagingDomainName) {
|
|
242
279
|
fail("cms.stagingDomainName", "must not be set when cms.staging is false");
|
|
243
280
|
}
|
|
@@ -300,9 +337,20 @@ function validateOauthRedirectUrls(config) {
|
|
|
300
337
|
}
|
|
301
338
|
}
|
|
302
339
|
}
|
|
340
|
+
function parseAws(value) {
|
|
341
|
+
if (value === undefined)
|
|
342
|
+
return undefined;
|
|
343
|
+
const record = requireObject(value, "aws");
|
|
344
|
+
rejectUnknownKeys(record, ["profile", "accountId", "region"], "aws");
|
|
345
|
+
return {
|
|
346
|
+
profile: optionalString(record.profile, "aws.profile", /^[A-Za-z0-9_.-]+$/, "AWS CLI profile name"),
|
|
347
|
+
accountId: optionalString(record.accountId, "aws.accountId", /^\d{12}$/, "12-digit account id"),
|
|
348
|
+
region: optionalString(record.region, "aws.region", /^[a-z]{2}(-[a-z]+)+-\d$/, "AWS region such as us-east-1"),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
303
351
|
export function parseSiteConfig(raw) {
|
|
304
352
|
const record = requireObject(raw, "site");
|
|
305
|
-
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
|
|
353
|
+
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"], "site");
|
|
306
354
|
const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
|
|
307
355
|
const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
|
|
308
356
|
const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
|
|
@@ -315,6 +363,7 @@ export function parseSiteConfig(raw) {
|
|
|
315
363
|
}
|
|
316
364
|
const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
|
|
317
365
|
const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
|
|
366
|
+
const aws = parseAws(record.aws);
|
|
318
367
|
const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
|
|
319
368
|
const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
|
|
320
369
|
const cms = record.cms === undefined && frontend === "markus-static" ? undefined : parseCms(record.cms);
|
|
@@ -352,12 +401,15 @@ export function parseSiteConfig(raw) {
|
|
|
352
401
|
if (cms?.stagingPreview !== undefined && cms.staging === false) {
|
|
353
402
|
fail("cms.stagingPreview", "must not be set when cms.staging is false");
|
|
354
403
|
}
|
|
404
|
+
const autoDeploy = record.autoDeploy ?? true;
|
|
405
|
+
if (typeof autoDeploy !== "boolean")
|
|
406
|
+
fail("autoDeploy", "must be a boolean");
|
|
355
407
|
let github;
|
|
356
408
|
if (record.github !== undefined) {
|
|
357
409
|
if (cms === undefined)
|
|
358
410
|
fail("github", "requires cms (the CI role is scoped to the CMS app)");
|
|
359
411
|
const githubRecord = requireObject(record.github, "github");
|
|
360
|
-
rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
|
|
412
|
+
rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra", "ownerId", "repoId"], "github");
|
|
361
413
|
const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
|
|
362
414
|
const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
|
|
363
415
|
if (repository !== `https://github.com/${owner}/${repo}`) {
|
|
@@ -378,7 +430,18 @@ export function parseSiteConfig(raw) {
|
|
|
378
430
|
const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
|
|
379
431
|
if (typeof ciCanDeployInfra !== "boolean")
|
|
380
432
|
fail("github.ciCanDeployInfra", "must be a boolean");
|
|
381
|
-
|
|
433
|
+
const optionalId = (value, name) => {
|
|
434
|
+
if (value === undefined)
|
|
435
|
+
return undefined;
|
|
436
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
|
|
437
|
+
fail(name, "must be a positive integer (GitHub numeric id)");
|
|
438
|
+
return value;
|
|
439
|
+
};
|
|
440
|
+
const ownerId = optionalId(githubRecord.ownerId, "github.ownerId");
|
|
441
|
+
const repoId = optionalId(githubRecord.repoId, "github.repoId");
|
|
442
|
+
if ((ownerId === undefined) !== (repoId === undefined))
|
|
443
|
+
fail("github", "ownerId and repoId must be set together");
|
|
444
|
+
github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
|
|
382
445
|
}
|
|
383
446
|
const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
|
|
384
447
|
if (hasDomain && hostedZoneId === undefined) {
|
|
@@ -395,13 +458,29 @@ export function parseSiteConfig(raw) {
|
|
|
395
458
|
frontend,
|
|
396
459
|
hostedZoneId,
|
|
397
460
|
stackName,
|
|
461
|
+
aws,
|
|
398
462
|
cms,
|
|
399
463
|
reader,
|
|
400
464
|
papyrusVersion,
|
|
401
465
|
storagePreviewPrefix,
|
|
466
|
+
autoDeploy: autoDeploy,
|
|
402
467
|
github,
|
|
403
468
|
};
|
|
404
469
|
if (hasCmsApp(config))
|
|
405
470
|
validateOauthRedirectUrls(config);
|
|
406
471
|
return config;
|
|
407
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* The CDK environment for a site's app-shell stack, taken only from the site's own `aws` block. There is
|
|
475
|
+
* deliberately no default account: a deploy that guesses an account can land in, or fail against, the wrong
|
|
476
|
+
* one.
|
|
477
|
+
*/
|
|
478
|
+
export function resolveDeployEnvironment(config) {
|
|
479
|
+
const account = config.aws?.accountId;
|
|
480
|
+
const region = config.aws?.region;
|
|
481
|
+
const missing = [account ? "" : "aws.accountId", region ? "" : "aws.region"].filter(Boolean);
|
|
482
|
+
if (!account || !region) {
|
|
483
|
+
throw new Error(`site "${config.siteId}" does not say where it deploys: add ${missing.join(" and ")} to its site.json, e.g. "aws": { "accountId": "<12-digit account>", "region": "us-east-1" }. No account is assumed.`);
|
|
484
|
+
}
|
|
485
|
+
return { account, region };
|
|
486
|
+
}
|
package/infra/site-config.ts
CHANGED
|
@@ -32,6 +32,8 @@ export type CmsConfig = {
|
|
|
32
32
|
appName?: string;
|
|
33
33
|
domainName?: string;
|
|
34
34
|
domainPrefix?: string;
|
|
35
|
+
/** Public https origin of a site whose host is served by its own edge (no Amplify domain association). */
|
|
36
|
+
siteOrigin?: string;
|
|
35
37
|
staging?: boolean;
|
|
36
38
|
stagingPreview?: StagingPreviewConfig;
|
|
37
39
|
cognitoDomainPrefix: string;
|
|
@@ -42,6 +44,12 @@ export type CmsConfig = {
|
|
|
42
44
|
stagingDomainPrefix?: string;
|
|
43
45
|
redirects?: CmsRedirect[];
|
|
44
46
|
prospectusAccountsApiStageArn?: string;
|
|
47
|
+
/**
|
|
48
|
+
* The site is a public-reader site that polls Prospectus for retired reader subjects. The template then generates a
|
|
49
|
+
* shared secret that the scheduled snapshot run presents to the CMS route `/api/reader/retired` (requires
|
|
50
|
+
* `prospectusAccountsApiStageArn`; docs/reader-steering.md).
|
|
51
|
+
*/
|
|
52
|
+
readerRetirementPoll?: boolean;
|
|
45
53
|
deployStackInBuild?: boolean;
|
|
46
54
|
};
|
|
47
55
|
|
|
@@ -53,6 +61,7 @@ export type AmplifyAppShellSiteConfig = {
|
|
|
53
61
|
frontend: "pretext" | "markus-static";
|
|
54
62
|
hostedZoneId?: string;
|
|
55
63
|
stackName?: string;
|
|
64
|
+
aws?: SiteAwsConfig;
|
|
56
65
|
cms?: CmsConfig;
|
|
57
66
|
reader?: {
|
|
58
67
|
appName?: string;
|
|
@@ -69,20 +78,62 @@ export type AmplifyAppShellSiteConfig = {
|
|
|
69
78
|
};
|
|
70
79
|
papyrusVersion: string;
|
|
71
80
|
storagePreviewPrefix?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Merge-to-main deploy from GitHub Actions through OIDC. When true (the default) the stack creates the
|
|
83
|
+
* `<siteId>-github-deploy` role and turns off Amplify's own auto-build on the production branches, so the
|
|
84
|
+
* public repository's workflow is the only trigger. Set false for a site whose repository is private:
|
|
85
|
+
* private repositories get no Actions minutes, so Amplify's own repository-connected auto-build stays on.
|
|
86
|
+
*/
|
|
87
|
+
autoDeploy: boolean;
|
|
72
88
|
github?: {
|
|
73
89
|
owner: string;
|
|
74
90
|
repo: string;
|
|
75
91
|
branches: string[];
|
|
76
92
|
ciCanDeployInfra: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* GitHub's numeric ids of the owner and the repository. Repositories with the immutable OIDC subject
|
|
95
|
+
* (`repo:<owner>@<ownerId>/<repo>@<repoId>:ref:...`, see `gh api repos/<owner>/<repo>/actions/oidc/customization/sub`)
|
|
96
|
+
* present that form instead of `repo:<owner>/<repo>:ref:...`; with both ids set, roles trust both forms.
|
|
97
|
+
*/
|
|
98
|
+
ownerId?: number;
|
|
99
|
+
repoId?: number;
|
|
77
100
|
};
|
|
78
101
|
};
|
|
79
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Where the site's AWS resources live. `bin/app-shell.ts` deploys the stack into exactly this account and
|
|
105
|
+
* region (see `resolveDeployEnvironment`), and `papyrus site google-secrets` asserts `accountId` with STS
|
|
106
|
+
* before it writes anything.
|
|
107
|
+
*/
|
|
108
|
+
export type SiteAwsConfig = {
|
|
109
|
+
profile?: string;
|
|
110
|
+
accountId?: string;
|
|
111
|
+
region?: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
80
114
|
export type CmsSiteConfig = AmplifyAppShellSiteConfig & { cms: CmsConfig };
|
|
81
115
|
|
|
82
116
|
export function hasCmsApp(config: AmplifyAppShellSiteConfig): config is CmsSiteConfig {
|
|
83
117
|
return config.cms !== undefined;
|
|
84
118
|
}
|
|
85
119
|
|
|
120
|
+
export function resolveGithubRepository(config: Pick<AmplifyAppShellSiteConfig, "repository" | "github">): { owner: string; repo: string } {
|
|
121
|
+
if (config.github) return { owner: config.github.owner, repo: config.github.repo };
|
|
122
|
+
const [owner, repo] = config.repository.replace("https://github.com/", "").split("/");
|
|
123
|
+
return { owner, repo };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The OIDC `sub` values a GitHub Actions run on `ref` of the site's repository can present. */
|
|
127
|
+
export function githubOidcSubjects(config: Pick<AmplifyAppShellSiteConfig, "repository" | "github">, ref: string): string[] {
|
|
128
|
+
const { owner, repo } = resolveGithubRepository(config);
|
|
129
|
+
const subjects = [`repo:${owner}/${repo}:ref:refs/heads/${ref}`];
|
|
130
|
+
const { ownerId, repoId } = config.github ?? {};
|
|
131
|
+
if (ownerId !== undefined && repoId !== undefined) {
|
|
132
|
+
subjects.push(`repo:${owner}@${ownerId}/${repo}@${repoId}:ref:refs/heads/${ref}`);
|
|
133
|
+
}
|
|
134
|
+
return subjects;
|
|
135
|
+
}
|
|
136
|
+
|
|
86
137
|
export const DEFAULT_CI_BRANCHES = ["main", "staging"];
|
|
87
138
|
|
|
88
139
|
const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
@@ -90,6 +141,7 @@ const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_
|
|
|
90
141
|
const SECRETS_MANAGER_SECRET_ARN_PATTERN = /^arn:aws:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+$/;
|
|
91
142
|
const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
|
|
92
143
|
const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
|
|
144
|
+
const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
|
|
93
145
|
const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
94
146
|
const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
|
|
95
147
|
const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
|
|
@@ -100,7 +152,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
|
100
152
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
101
153
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
102
154
|
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
103
|
-
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "
|
|
155
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
|
|
104
156
|
|
|
105
157
|
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
106
158
|
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
@@ -127,6 +179,14 @@ function originOfRedirectUrl(value: string): string {
|
|
|
127
179
|
}
|
|
128
180
|
}
|
|
129
181
|
|
|
182
|
+
export function resolveProspectusSessionSealingParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
|
|
183
|
+
return `/papyrus/${config.siteId}/prospectus-session-sealing`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function resolveReaderPollSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
|
|
187
|
+
return `/papyrus/${config.siteId}/reader-poll-secret`;
|
|
188
|
+
}
|
|
189
|
+
|
|
130
190
|
export function resolveRevalidateSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
|
|
131
191
|
return `/papyrus/${config.siteId}/revalidate-secret`;
|
|
132
192
|
}
|
|
@@ -251,6 +311,10 @@ export function resolveCmsHostName(config: Pick<CmsSiteConfig, "cms">): string |
|
|
|
251
311
|
return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
|
|
252
312
|
}
|
|
253
313
|
|
|
314
|
+
export function resolveSiteOrigin(config: Pick<CmsSiteConfig, "cms">): string | undefined {
|
|
315
|
+
return config.cms.siteOrigin;
|
|
316
|
+
}
|
|
317
|
+
|
|
254
318
|
export function resolveStagingDomainName(config: Pick<CmsSiteConfig, "cms">): string | undefined {
|
|
255
319
|
if (!isStagingEnabled(config)) return undefined;
|
|
256
320
|
if (config.cms.domainPrefix) {
|
|
@@ -282,7 +346,7 @@ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
|
|
|
282
346
|
|
|
283
347
|
function parseCms(value: unknown): CmsConfig {
|
|
284
348
|
const cmsRecord = requireObject(value, "cms");
|
|
285
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
|
|
349
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "readerRetirementPoll", "deployStackInBuild"], "cms");
|
|
286
350
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
287
351
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
288
352
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -293,6 +357,12 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
293
357
|
if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
|
|
294
358
|
fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
|
|
295
359
|
}
|
|
360
|
+
if (cmsRecord.readerRetirementPoll !== undefined && typeof cmsRecord.readerRetirementPoll !== "boolean") {
|
|
361
|
+
fail("cms.readerRetirementPoll", `must be a boolean, got ${JSON.stringify(cmsRecord.readerRetirementPoll)}`);
|
|
362
|
+
}
|
|
363
|
+
if (cmsRecord.readerRetirementPoll === true && cmsRecord.prospectusAccountsApiStageArn === undefined) {
|
|
364
|
+
fail("cms.readerRetirementPoll", "requires cms.prospectusAccountsApiStageArn: the poll calls Prospectus as the site");
|
|
365
|
+
}
|
|
296
366
|
if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
|
|
297
367
|
fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
|
|
298
368
|
}
|
|
@@ -300,6 +370,7 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
300
370
|
appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
|
|
301
371
|
domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
|
|
302
372
|
domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
373
|
+
siteOrigin: optionalString(cmsRecord.siteOrigin, "cms.siteOrigin", SITE_ORIGIN_PATTERN, "https origin with no path, query or trailing slash, such as https://shipped.anth.us"),
|
|
303
374
|
staging: cmsRecord.staging as boolean | undefined,
|
|
304
375
|
cognitoDomainPrefix: requireString(
|
|
305
376
|
cmsRecord.cognitoDomainPrefix,
|
|
@@ -314,6 +385,7 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
314
385
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
315
386
|
redirects: parseRedirects(cmsRecord.redirects),
|
|
316
387
|
deployStackInBuild: cmsRecord.deployStackInBuild as boolean | undefined,
|
|
388
|
+
readerRetirementPoll: cmsRecord.readerRetirementPoll as boolean | undefined,
|
|
317
389
|
stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
|
|
318
390
|
prospectusAccountsApiStageArn: optionalString(
|
|
319
391
|
cmsRecord.prospectusAccountsApiStageArn,
|
|
@@ -329,10 +401,13 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
329
401
|
}
|
|
330
402
|
for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
|
|
331
403
|
if (key in cms.environment) {
|
|
332
|
-
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
|
|
404
|
+
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn and the public site origin comes from the site host");
|
|
333
405
|
}
|
|
334
406
|
}
|
|
335
407
|
|
|
408
|
+
if (cms.siteOrigin && cms.domainName) {
|
|
409
|
+
fail("cms.siteOrigin", "is for sites whose host is served by their own edge; remove it when cms.domainName is set");
|
|
410
|
+
}
|
|
336
411
|
if (cms.staging === false && cms.stagingDomainName) {
|
|
337
412
|
fail("cms.stagingDomainName", "must not be set when cms.staging is false");
|
|
338
413
|
}
|
|
@@ -395,11 +470,22 @@ function validateOauthRedirectUrls(config: CmsSiteConfig): void {
|
|
|
395
470
|
}
|
|
396
471
|
}
|
|
397
472
|
|
|
473
|
+
function parseAws(value: unknown): SiteAwsConfig | undefined {
|
|
474
|
+
if (value === undefined) return undefined;
|
|
475
|
+
const record = requireObject(value, "aws");
|
|
476
|
+
rejectUnknownKeys(record, ["profile", "accountId", "region"], "aws");
|
|
477
|
+
return {
|
|
478
|
+
profile: optionalString(record.profile, "aws.profile", /^[A-Za-z0-9_.-]+$/, "AWS CLI profile name"),
|
|
479
|
+
accountId: optionalString(record.accountId, "aws.accountId", /^\d{12}$/, "12-digit account id"),
|
|
480
|
+
region: optionalString(record.region, "aws.region", /^[a-z]{2}(-[a-z]+)+-\d$/, "AWS region such as us-east-1"),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
398
484
|
export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
399
485
|
const record = requireObject(raw, "site");
|
|
400
486
|
rejectUnknownKeys(
|
|
401
487
|
record,
|
|
402
|
-
["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
|
|
488
|
+
["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"],
|
|
403
489
|
"site",
|
|
404
490
|
);
|
|
405
491
|
|
|
@@ -415,6 +501,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
415
501
|
}
|
|
416
502
|
const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
|
|
417
503
|
const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
|
|
504
|
+
const aws = parseAws(record.aws);
|
|
418
505
|
const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
|
|
419
506
|
const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
|
|
420
507
|
|
|
@@ -452,11 +539,14 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
452
539
|
fail("cms.stagingPreview", "must not be set when cms.staging is false");
|
|
453
540
|
}
|
|
454
541
|
|
|
542
|
+
const autoDeploy = record.autoDeploy ?? true;
|
|
543
|
+
if (typeof autoDeploy !== "boolean") fail("autoDeploy", "must be a boolean");
|
|
544
|
+
|
|
455
545
|
let github: AmplifyAppShellSiteConfig["github"];
|
|
456
546
|
if (record.github !== undefined) {
|
|
457
547
|
if (cms === undefined) fail("github", "requires cms (the CI role is scoped to the CMS app)");
|
|
458
548
|
const githubRecord = requireObject(record.github, "github");
|
|
459
|
-
rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
|
|
549
|
+
rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra", "ownerId", "repoId"], "github");
|
|
460
550
|
const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
|
|
461
551
|
const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
|
|
462
552
|
if (repository !== `https://github.com/${owner}/${repo}`) {
|
|
@@ -476,7 +566,15 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
476
566
|
}
|
|
477
567
|
const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
|
|
478
568
|
if (typeof ciCanDeployInfra !== "boolean") fail("github.ciCanDeployInfra", "must be a boolean");
|
|
479
|
-
|
|
569
|
+
const optionalId = (value: unknown, name: string): number | undefined => {
|
|
570
|
+
if (value === undefined) return undefined;
|
|
571
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) fail(name, "must be a positive integer (GitHub numeric id)");
|
|
572
|
+
return value as number;
|
|
573
|
+
};
|
|
574
|
+
const ownerId = optionalId(githubRecord.ownerId, "github.ownerId");
|
|
575
|
+
const repoId = optionalId(githubRecord.repoId, "github.repoId");
|
|
576
|
+
if ((ownerId === undefined) !== (repoId === undefined)) fail("github", "ownerId and repoId must be set together");
|
|
577
|
+
github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
|
|
480
578
|
}
|
|
481
579
|
|
|
482
580
|
const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
|
|
@@ -495,10 +593,12 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
495
593
|
frontend,
|
|
496
594
|
hostedZoneId,
|
|
497
595
|
stackName,
|
|
596
|
+
aws,
|
|
498
597
|
cms,
|
|
499
598
|
reader,
|
|
500
599
|
papyrusVersion,
|
|
501
600
|
storagePreviewPrefix,
|
|
601
|
+
autoDeploy: autoDeploy as boolean,
|
|
502
602
|
github,
|
|
503
603
|
};
|
|
504
604
|
|
|
@@ -506,3 +606,20 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
506
606
|
|
|
507
607
|
return config;
|
|
508
608
|
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* The CDK environment for a site's app-shell stack, taken only from the site's own `aws` block. There is
|
|
612
|
+
* deliberately no default account: a deploy that guesses an account can land in, or fail against, the wrong
|
|
613
|
+
* one.
|
|
614
|
+
*/
|
|
615
|
+
export function resolveDeployEnvironment(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "aws">): { account: string; region: string } {
|
|
616
|
+
const account = config.aws?.accountId;
|
|
617
|
+
const region = config.aws?.region;
|
|
618
|
+
const missing = [account ? "" : "aws.accountId", region ? "" : "aws.region"].filter(Boolean);
|
|
619
|
+
if (!account || !region) {
|
|
620
|
+
throw new Error(
|
|
621
|
+
`site "${config.siteId}" does not say where it deploys: add ${missing.join(" and ")} to its site.json, e.g. "aws": { "accountId": "<12-digit account>", "region": "us-east-1" }. No account is assumed.`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
return { account, region };
|
|
625
|
+
}
|
|
@@ -200,7 +200,6 @@ export type ProcedureRunRecord = {
|
|
|
200
200
|
newsroomFeedKey?: string | null;
|
|
201
201
|
};
|
|
202
202
|
|
|
203
|
-
const DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH = path.join(process.cwd(), "corpora", "papyrus-newsroom-sections.yml");
|
|
204
203
|
const NEWSROOM_SECTION_TYPES = new Set<NewsroomSectionType>(["canonical", "floating", "rotating"]);
|
|
205
204
|
type NewsroomSectionSeedRow = Omit<NewsroomSectionRecord, "sortOrder" | "enabled" | "enabledStatus" | "createdAt" | "updatedAt"> & {
|
|
206
205
|
enabled: boolean;
|
|
@@ -690,10 +689,10 @@ export type CategorySteeringDashboard = {
|
|
|
690
689
|
loadError?: string | null;
|
|
691
690
|
};
|
|
692
691
|
|
|
693
|
-
const DEFAULT_ANALYSIS_PROFILES_PATH = path.join(process.cwd(), "corpora", "papyrus-analysis-profiles.yml");
|
|
694
|
-
const DEFAULT_STEERING_CONFIG_PATH = path.join(process.cwd(), "corpora", "papyrus-steering.yml");
|
|
695
692
|
|
|
696
|
-
|
|
693
|
+
/** Analysis profiles the site keeps in a file; a site that configures none (data-driven sites) has none. No default file is looked up. */
|
|
694
|
+
export async function loadAnalysisProfileSummaries(filepath?: string): Promise<AnalysisProfileSummary[]> {
|
|
695
|
+
if (!filepath) return [];
|
|
697
696
|
try {
|
|
698
697
|
const parsed = YAML.parse(fs.readFileSync(filepath, "utf8")) as {
|
|
699
698
|
schemaVersion?: number;
|
|
@@ -722,7 +721,9 @@ export async function loadAnalysisProfileSummaries(filepath = DEFAULT_ANALYSIS_P
|
|
|
722
721
|
}
|
|
723
722
|
}
|
|
724
723
|
|
|
725
|
-
|
|
724
|
+
/** Corpora a site lists in a steering file; no configured file means none. No default file is looked up. */
|
|
725
|
+
export async function loadConfiguredCorpusSummaries(filepath?: string): Promise<CategorySteeringCorpus[]> {
|
|
726
|
+
if (!filepath) return [];
|
|
726
727
|
try {
|
|
727
728
|
const parsed = YAML.parse(fs.readFileSync(filepath, "utf8")) as {
|
|
728
729
|
corpora?: Array<Record<string, unknown>>;
|
|
@@ -818,7 +819,8 @@ function sortCategorys(categorys: CategorySteeringCategory[]): CategorySteeringC
|
|
|
818
819
|
});
|
|
819
820
|
}
|
|
820
821
|
|
|
821
|
-
function defaultNewsroomSections(importedAt: string, sectionsConfigPath
|
|
822
|
+
function defaultNewsroomSections(importedAt: string, sectionsConfigPath?: string): NewsroomSectionRecord[] {
|
|
823
|
+
if (!sectionsConfigPath) return [];
|
|
822
824
|
return loadNewsroomSectionSeedRows(sectionsConfigPath).map((row, index) => ({
|
|
823
825
|
...row,
|
|
824
826
|
enabledStatus: "enabled",
|
|
@@ -828,7 +830,7 @@ function defaultNewsroomSections(importedAt: string, sectionsConfigPath = DEFAUL
|
|
|
828
830
|
}));
|
|
829
831
|
}
|
|
830
832
|
|
|
831
|
-
function loadNewsroomSectionSeedRows(sectionsConfigPath
|
|
833
|
+
function loadNewsroomSectionSeedRows(sectionsConfigPath: string) {
|
|
832
834
|
const cached = newsroomSectionSeedRowsCache.get(sectionsConfigPath);
|
|
833
835
|
if (cached) return cached;
|
|
834
836
|
const parsed = YAML.parse(fs.readFileSync(sectionsConfigPath, "utf8")) as {
|
|
@@ -883,7 +885,7 @@ function normalizeNewsroomSectionSeedRow(
|
|
|
883
885
|
|
|
884
886
|
export function createDemoCategorySteeringDashboard(
|
|
885
887
|
brandId: SiteBrandId = resolveSiteBrandId(),
|
|
886
|
-
newsroomSectionsConfigPath
|
|
888
|
+
newsroomSectionsConfigPath?: string,
|
|
887
889
|
): CategorySteeringDashboard {
|
|
888
890
|
const importedAt = "2026-05-16T12:00:00.000Z";
|
|
889
891
|
const profile = getNewsroomDemoProfile(brandId);
|
|
@@ -1698,7 +1700,7 @@ export function createDemoCategorySteeringDashboard(
|
|
|
1698
1700
|
question: "Should this candidate source become a reference for this publication?",
|
|
1699
1701
|
reviewControl: "thumbs",
|
|
1700
1702
|
reviewReason: "program",
|
|
1701
|
-
reviewDetail: "Every
|
|
1703
|
+
reviewDetail: "Every suggestion is reviewed while suggestions are getting started.",
|
|
1702
1704
|
version: 1,
|
|
1703
1705
|
}),
|
|
1704
1706
|
},
|