@anthusai/papyrus 1.0.0-next.9 → 1.1.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/auth/prospectus-google-client.js +66 -0
- package/amplify/auth/prospectus-google-client.ts +71 -0
- package/amplify/auth/redirect-urls.js +54 -0
- package/amplify/auth/redirect-urls.ts +66 -0
- package/amplify/auth/resource.js +23 -5
- package/amplify/auth/resource.ts +31 -5
- package/amplify/data/resource.js +42 -80
- package/amplify/data/resource.ts +42 -80
- package/amplify/data/schema.js +45 -62
- package/amplify/data/schema.ts +47 -62
- package/amplify/functions/category-action/handler.ts +5 -3
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
- package/amplify/functions/console-chat-responder/resource.js +0 -7
- package/amplify/functions/console-chat-responder/resource.ts +0 -8
- package/amplify/functions/console-chat-responder/src/main.rs +0 -9
- package/amplify/functions/content-actions/handler.py +143 -1
- package/amplify/functions/content-actions/requirements.txt +2 -1
- package/amplify/functions/content-actions/resource.js +6 -1
- package/amplify/functions/content-actions/resource.ts +9 -2
- package/amplify/functions/email-submission-processor/handler.py +3 -1
- package/amplify/functions/renditions/handler.py +120 -0
- package/amplify/functions/renditions/requirements.txt +4 -0
- package/amplify/functions/ses-inbound-receive/handler.ts +32 -4
- package/amplify/functions/shared/email-submission.ts +109 -1
- package/amplify/functions/shared/python-bundle.js +42 -2
- package/amplify/functions/shared/python-bundle.ts +47 -2
- package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
- package/amplify/inbound-email/ses-intake.js +86 -0
- package/amplify/inbound-email/ses-intake.ts +110 -0
- package/amplify/plugins/registry.js +19 -0
- package/amplify/plugins/registry.ts +23 -0
- package/amplify/plugins/signup/function/handler.py +200 -0
- package/amplify/plugins/signup/function/resource.js +27 -0
- package/amplify/plugins/signup/function/resource.ts +35 -0
- package/amplify/plugins/signup/papyrus.plugin.js +59 -0
- package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
- package/amplify/plugins/signup/schema.js +40 -0
- package/amplify/plugins/signup/schema.ts +41 -0
- package/amplify/plugins/signup/waf.js +48 -0
- package/amplify/plugins/signup/waf.ts +51 -0
- package/amplify/plugins/types.js +1 -0
- package/amplify/plugins/types.ts +39 -0
- package/amplify/plugins/wire.js +24 -0
- package/amplify/plugins/wire.ts +26 -0
- package/amplify/publication/default-publication.js +18 -0
- package/amplify/publication/default-publication.ts +20 -0
- package/amplify/publication/invalidation-alarm.js +26 -0
- package/amplify/publication/invalidation-alarm.ts +30 -0
- package/amplify/publication/redirect-and-index.js +37 -0
- package/amplify/publication/renditions-function.js +74 -0
- package/amplify/publication/renditions-function.ts +112 -0
- package/amplify/publication/renditions-settings.js +51 -0
- package/amplify/publication/renditions-settings.ts +58 -0
- package/amplify/publication/reproject-on-deploy.js +73 -0
- package/amplify/publication/reproject-on-deploy.ts +78 -0
- package/amplify/publication/s3-projection-settings.js +67 -0
- package/amplify/publication/s3-projection-settings.ts +101 -0
- package/amplify/publication/s3-projection.js +151 -0
- package/amplify/publication/s3-projection.ts +201 -0
- package/amplify/resolver-waves.js +26 -0
- package/amplify/resolver-waves.ts +34 -0
- package/amplify/revalidate-invalidation-settings.js +30 -0
- package/amplify/revalidate-invalidation-settings.ts +35 -0
- package/amplify/revalidate-secret-policy.js +13 -0
- package/amplify/revalidate-secret-policy.ts +19 -0
- package/amplify/site-backend-config.js +65 -1
- package/amplify/site-backend-config.ts +153 -2
- package/amplify/site-backend-names.js +103 -0
- package/amplify/site-backend-names.ts +166 -0
- package/amplify/site-backend.js +150 -176
- package/amplify/site-backend.ts +189 -201
- package/amplify/storage-backups/construct.js +31 -0
- package/amplify/storage-backups/construct.ts +47 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
- package/app/[year]/[month]/[day]/page.tsx +1 -1
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
- package/app/api/archive/editions/route.ts +1 -1
- package/app/api/auth/prospectus/callback/route.ts +9 -0
- package/app/api/auth/prospectus/selection/route.ts +9 -0
- package/app/api/auth/prospectus/session/route.ts +9 -0
- package/app/api/auth/prospectus/sign-in/route.ts +9 -0
- package/app/api/auth/prospectus/sign-out/route.ts +9 -0
- package/app/api/cyclotron/priming-proof/route.ts +11 -0
- package/app/api/media/[...path]/route.ts +5 -16
- package/app/api/revalidate/route.ts +15 -7
- package/app/archive/page.tsx +1 -1
- package/app/articles/[slug]/page.tsx +7 -5
- package/app/blog-rhythm.css +278 -0
- package/app/cyclotron/page.tsx +13 -0
- package/app/globals.css +2 -0
- package/app/layout.tsx +16 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
- package/app/newsroom/[section]/page.tsx +6 -3
- package/app/newsroom/layout.tsx +4 -9
- package/app/newsroom/settings/page.tsx +6 -0
- package/app/newsroom/work/page.tsx +10 -0
- package/app/page.tsx +22 -8
- package/app/robots.ts +2 -5
- package/app/tailwind.css +1 -6
- package/components/amplify-client-provider.tsx +5 -22
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +152 -5
- package/components/blog-page-background.tsx +23 -6
- package/components/news-desk-page.tsx +4 -15
- package/components/news-desk-taxonomy-client.ts +90 -3
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +2 -1
- package/components/newsroom-board-view.tsx +99 -0
- package/components/newsroom-client-shell.tsx +12 -0
- package/components/newsroom-curation-provider.tsx +17 -0
- 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 +93 -0
- 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 +93 -0
- package/components/newsroom-newsprint-board.css +48 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +7 -1
- package/components/newsroom-organizer-shell.tsx +130 -0
- package/components/newsroom-organizer.css +63 -0
- package/components/newsroom-references-view.tsx +92 -8
- package/components/papyrus-console-shell.tsx +4 -2
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/priming-proof-page.tsx +94 -0
- package/components/publication-cyclotron-panel.tsx +57 -0
- package/components/reader-auth-control.tsx +8 -5
- package/components/reference-curation-controls.tsx +77 -0
- package/components/reference-cyclotron-status.tsx +109 -0
- package/components/reference-priming.tsx +18 -0
- package/components/reference-relevance-review.tsx +75 -0
- package/components/settings-page.tsx +2 -1
- package/components/signup-entries-panel.tsx +94 -0
- package/components/topic-steering-workspace.tsx +78 -8
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +28 -0
- package/components/use-near-viewport.ts +30 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/infra/amplify-app-shell.js +166 -27
- package/infra/amplify-app-shell.ts +180 -31
- package/infra/build-specs.js +23 -7
- package/infra/build-specs.ts +27 -10
- package/infra/site-config.js +248 -36
- package/infra/site-config.ts +313 -51
- package/lib/amplify-outputs-path.ts +5 -1
- package/lib/amplify-server-runtime.ts +36 -1
- package/lib/approved-priming-proof-reports.ts +6 -0
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/brand-fonts-watch.ts +43 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/cached-content-repository.ts +12 -0
- package/lib/category-repository.ts +43 -93
- package/lib/content-item-lifecycle.ts +55 -0
- package/lib/content-repository.ts +3 -0
- package/lib/content-types.ts +4 -0
- package/lib/curation-client.ts +61 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/graphql-content-repository.ts +56 -22
- package/lib/media-route-response.ts +45 -0
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-base-path.ts +12 -3
- package/lib/newsroom-board.ts +99 -0
- package/lib/newsroom-category-drilldown.ts +3 -2
- package/lib/newsroom-forum-routes.ts +6 -4
- package/lib/newsroom-index-filters.ts +14 -10
- package/lib/newsroom-nav.ts +3 -1
- package/lib/newsroom-organizer-nav.ts +37 -0
- package/lib/newsroom-section-routing.ts +20 -0
- package/lib/oauth-redirect-priority.ts +40 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/plugin-settings.ts +11 -0
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +12 -3
- package/lib/priming-proof-report.ts +90 -0
- package/lib/prospectus-publication-access.ts +53 -0
- package/lib/prospectus-session.ts +196 -0
- package/lib/prospectus-sign-in-routes.ts +284 -0
- package/lib/prospectus-site-identity.ts +303 -0
- package/lib/publication-cyclotron.ts +74 -0
- package/lib/rate-limit-retry.ts +55 -0
- package/lib/reader-base-path.ts +24 -0
- package/lib/reader-cache-lifetime.ts +5 -0
- package/lib/reader-static-params.ts +85 -4
- package/lib/reader-storage-url.ts +9 -12
- package/lib/reference-policy.ts +5 -0
- package/lib/relevance-decisions.ts +103 -0
- package/lib/relevance-status.ts +63 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-prewarm.ts +53 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/robots-policy.ts +14 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/semantic-graph.ts +7 -0
- package/lib/shared-credentials-provider.ts +61 -0
- package/lib/signup-csv.ts +24 -0
- package/lib/site-brand.ts +59 -0
- package/lib/staging-gated-path.ts +23 -0
- package/lib/staging-preview-object.ts +21 -5
- package/lib/test-editor-override.ts +4 -0
- package/lib/video-loading.ts +22 -0
- package/lib/video-script.ts +14 -0
- package/middleware.ts +11 -19
- package/package.json +73 -8
- package/renderers/pretext/article-page.tsx +39 -3
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +15 -102
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +123 -12
- package/src/sync.mjs +30 -6
- package/src/with-papyrus.d.mts +9 -0
- package/src/with-papyrus.mjs +48 -3
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
- package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
- package/components/ai-elements/message.tsx +0 -360
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { PluginRequest } from "./plugins/types";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Per-site backend configuration: the argument of `defineSiteBackend(site)`.
|
|
3
5
|
* Config is passed in; the environment is only a fallback so Papyrus's own
|
|
@@ -7,10 +9,125 @@ export type PapyrusAuthConfig = {
|
|
|
7
9
|
/** OAuth callback/logout URLs for Google sign-in (ignored when Google is disabled). */
|
|
8
10
|
redirectUrls?: string[];
|
|
9
11
|
disableGoogleOAuth?: boolean;
|
|
10
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Public newsroom path prefix for sign-in return URLs. Default `/newsroom`;
|
|
14
|
+
* `""` on a CMS-only host where the newsroom is the root. Filled from the
|
|
15
|
+
* active brand's `newsroomBasePath` by `defineSiteBackend` when unset.
|
|
16
|
+
*/
|
|
17
|
+
newsroomBasePath?: string;
|
|
18
|
+
/** Desired Cognito hosted-UI domain prefix. Only applied when `applyCognitoDomainPrefix` is true. */
|
|
11
19
|
cognitoDomainPrefix?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Opt in to the prefix. Off by default because changing a live pool's
|
|
22
|
+
* domain replaces it and changes the Google redirect URI.
|
|
23
|
+
*/
|
|
24
|
+
applyCognitoDomainPrefix?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type S3ProjectionDistributionConfig = {
|
|
28
|
+
create: boolean;
|
|
29
|
+
aliases?: string[];
|
|
30
|
+
certificateArn?: string;
|
|
31
|
+
acm?: { hostedZoneId: string; hostedZoneName: string };
|
|
12
32
|
};
|
|
13
33
|
|
|
34
|
+
export type PapyrusPublicationCacheConfig = {
|
|
35
|
+
/** Shared-cache lifetime (seconds) of projected HTML. Default 300 with targeted invalidation, 60 with `invalidation: "off"`. */
|
|
36
|
+
htmlSMaxAgeSeconds?: number;
|
|
37
|
+
/** `targeted` (default): purge exactly the written paths after each projection. `off`: rely on the short lifetime alone. */
|
|
38
|
+
invalidation?: "targeted" | "off";
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const MAXIMUM_HTML_S_MAXAGE_SECONDS = 86400;
|
|
42
|
+
|
|
43
|
+
export type PapyrusPublicationConfig = {
|
|
44
|
+
kind: "s3-projection";
|
|
45
|
+
/** Site repo path (relative, `.py`) of the SiteProjector bundled into content-actions as `site_projector`. */
|
|
46
|
+
projector?: string;
|
|
47
|
+
/** Site repo directory (relative) bundled into content-actions as `site_static/`. */
|
|
48
|
+
staticDir?: string;
|
|
49
|
+
/** Existing CloudFront distribution that reads the site bucket through OAC. */
|
|
50
|
+
edgeDistributionArn?: string;
|
|
51
|
+
/** Edge cache lifetime of projected HTML and whether each projection purges the edge. */
|
|
52
|
+
cache?: PapyrusPublicationCacheConfig;
|
|
53
|
+
/** Create the distribution (and optionally its certificate) in the site backend. Default: not created. */
|
|
54
|
+
distribution?: S3ProjectionDistributionConfig;
|
|
55
|
+
/** Default true: the site bucket is retained when the stack is deleted. `false` destroys it and its objects. */
|
|
56
|
+
retainBucketOnDelete?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Re-project every published page after each backend deploy, so a theme or code change reaches the
|
|
59
|
+
* site without a manual `papyrus ops content project --all`. Default: true for the publication a
|
|
60
|
+
* Markus site gets when it sets none, false when `publication` is written out in the site config.
|
|
61
|
+
*/
|
|
62
|
+
reprojectOnDeploy?: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const CLOUDFRONT_DISTRIBUTION_ARN = /^arn:aws[a-z-]*:cloudfront::\d{12}:distribution\/[A-Z0-9]+$/;
|
|
66
|
+
|
|
67
|
+
function isRelativePathWithoutParentSegments(value: string): boolean {
|
|
68
|
+
if (value === "" || value.startsWith("/") || value.includes("\\")) return false;
|
|
69
|
+
return !value.split("/").includes("..");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function validatePublicationConfig(publication: unknown): string[] {
|
|
73
|
+
const problems: string[] = [];
|
|
74
|
+
if (typeof publication !== "object" || publication === null) {
|
|
75
|
+
return ["publication must be an object"];
|
|
76
|
+
}
|
|
77
|
+
const config = publication as Partial<PapyrusPublicationConfig> & { kind?: unknown };
|
|
78
|
+
if (config.kind !== "s3-projection") {
|
|
79
|
+
problems.push(`publication.kind must be "s3-projection" (got ${JSON.stringify(config.kind)})`);
|
|
80
|
+
}
|
|
81
|
+
if (config.projector !== undefined) {
|
|
82
|
+
if (typeof config.projector !== "string" || !isRelativePathWithoutParentSegments(config.projector) || !config.projector.endsWith(".py")) {
|
|
83
|
+
problems.push("publication.projector must be a relative .py path without '..'");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (config.staticDir !== undefined) {
|
|
87
|
+
if (typeof config.staticDir !== "string" || !isRelativePathWithoutParentSegments(config.staticDir)) {
|
|
88
|
+
problems.push("publication.staticDir must be a relative path without '..'");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (config.edgeDistributionArn !== undefined && !CLOUDFRONT_DISTRIBUTION_ARN.test(config.edgeDistributionArn)) {
|
|
92
|
+
problems.push("publication.edgeDistributionArn must be a CloudFront distribution ARN (arn:aws:cloudfront::<account>:distribution/<id>)");
|
|
93
|
+
}
|
|
94
|
+
if (config.reprojectOnDeploy !== undefined && typeof config.reprojectOnDeploy !== "boolean") {
|
|
95
|
+
problems.push("publication.reprojectOnDeploy must be a boolean");
|
|
96
|
+
}
|
|
97
|
+
const cache = config.cache;
|
|
98
|
+
if (cache !== undefined) {
|
|
99
|
+
const lifetime = cache.htmlSMaxAgeSeconds;
|
|
100
|
+
if (lifetime !== undefined && (!Number.isInteger(lifetime) || lifetime < 0 || lifetime > MAXIMUM_HTML_S_MAXAGE_SECONDS)) {
|
|
101
|
+
problems.push(`publication.cache.htmlSMaxAgeSeconds must be an integer from 0 to ${MAXIMUM_HTML_S_MAXAGE_SECONDS}`);
|
|
102
|
+
}
|
|
103
|
+
if (cache.invalidation !== undefined && cache.invalidation !== "targeted" && cache.invalidation !== "off") {
|
|
104
|
+
problems.push('publication.cache.invalidation must be "targeted" or "off"');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const distribution = config.distribution;
|
|
108
|
+
if (distribution !== undefined) {
|
|
109
|
+
if (config.edgeDistributionArn !== undefined && distribution.create === true) {
|
|
110
|
+
problems.push("publication.edgeDistributionArn and publication.distribution.create are mutually exclusive");
|
|
111
|
+
}
|
|
112
|
+
const aliases = distribution.aliases ?? [];
|
|
113
|
+
if (aliases.length > 0 && distribution.create !== true) {
|
|
114
|
+
problems.push("publication.distribution.aliases require distribution.create to be true");
|
|
115
|
+
}
|
|
116
|
+
if (aliases.length > 0 && !distribution.certificateArn && !distribution.acm) {
|
|
117
|
+
problems.push("publication.distribution.aliases require certificateArn or acm");
|
|
118
|
+
}
|
|
119
|
+
if (distribution.acm !== undefined && (!distribution.acm.hostedZoneId || !distribution.acm.hostedZoneName)) {
|
|
120
|
+
problems.push("publication.distribution.acm requires hostedZoneId and hostedZoneName");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return problems;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function assertValidPublicationConfig(publication: unknown): void {
|
|
127
|
+
const problems = validatePublicationConfig(publication);
|
|
128
|
+
if (problems.length > 0) throw new Error(`Invalid backend.publication: ${problems.join("; ")}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
14
131
|
export type PapyrusSiteBackendConfig = {
|
|
15
132
|
/** Brand id; namespaces account-global resources (S3 Vectors index name). */
|
|
16
133
|
brandId?: string;
|
|
@@ -21,12 +138,46 @@ export type PapyrusSiteBackendConfig = {
|
|
|
21
138
|
*/
|
|
22
139
|
productionAppId?: string;
|
|
23
140
|
auth?: PapyrusAuthConfig;
|
|
141
|
+
/**
|
|
142
|
+
* Maximum number of data model nested stacks CloudFormation creates at once.
|
|
143
|
+
* Stacks are chained in waves of this size so a new AWS account is not hit
|
|
144
|
+
* with every AppSync CreateResolver call in parallel (429). Only adds
|
|
145
|
+
* DependsOn ordering; no resource changes. Default 12; 0 turns it off.
|
|
146
|
+
*/
|
|
147
|
+
resolverWaveSize?: number;
|
|
24
148
|
/** Static reader app (Markus-static sites) that a publish rebuilds through IAM `amplify:StartJob`. */
|
|
25
149
|
reader?: { amplifyAppId: string; branchName?: string };
|
|
26
150
|
/** Lets the newsroom start the `staging` branch build of this CMS app. */
|
|
27
151
|
stagingBuild?: { enabled: boolean };
|
|
28
|
-
/** Base URL of a Pretext reader whose `/api/revalidate` a publish calls (secret `
|
|
152
|
+
/** Base URL of a Pretext reader whose `/api/revalidate` a publish calls (authenticated by the SSM secret named in branch variable `PAPYRUS_REVALIDATE_SECRET_PARAMETER`). */
|
|
29
153
|
revalidateBaseUrl?: string;
|
|
154
|
+
/**
|
|
155
|
+
* CloudFront distribution ids (same account) in front of that reader. After a publish refreshes the reader's
|
|
156
|
+
* cache, content-actions invalidates the revalidated paths on each of them. Needs `revalidateBaseUrl`.
|
|
157
|
+
*/
|
|
158
|
+
revalidateDistributionIds?: string[];
|
|
159
|
+
/**
|
|
160
|
+
* SES receipt options for a site that is not the legacy p.apyr.us backend.
|
|
161
|
+
* By default the site gets its own brand-named rule set that is never
|
|
162
|
+
* activated by CloudFormation (SES allows one active rule set per region).
|
|
163
|
+
*/
|
|
164
|
+
inboundEmailSes?: {
|
|
165
|
+
/** Add this site's rule to a rule set another stack owns instead of creating one. */
|
|
166
|
+
existingReceiptRuleSetName?: string;
|
|
167
|
+
/** Let this stack verify the SES domain identity and write its TXT record. Default false: verify once by hand. */
|
|
168
|
+
manageDomainIdentity?: boolean;
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* Static projection of published pages into an S3 site bucket. Unset: a Markus site without a
|
|
172
|
+
* `reader` gets `{ kind: "s3-projection" }`; any other site gets nothing. `false` opts a Markus
|
|
173
|
+
* site out. See `resolveSitePublication`.
|
|
174
|
+
*/
|
|
175
|
+
publication?: PapyrusPublicationConfig | false;
|
|
176
|
+
/**
|
|
177
|
+
* Deploy-time plugins (docs/plugins.md): `["signup"]`, or `[{ name: "signup", forms: ["news"] }]`.
|
|
178
|
+
* Unset: no plugin, and the synthesized backend is exactly what it was before plugins existed.
|
|
179
|
+
*/
|
|
180
|
+
plugins?: PluginRequest[];
|
|
30
181
|
features?: {
|
|
31
182
|
consoleResponder?: boolean;
|
|
32
183
|
inboundEmail?: boolean;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Token } from "aws-cdk-lib";
|
|
3
|
+
export function sanitizeAwsName(value, maxLength = 50) {
|
|
4
|
+
return value
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
7
|
+
.replace(/-+/g, "-")
|
|
8
|
+
.replace(/^-|-$/g, "")
|
|
9
|
+
.slice(0, maxLength);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Account-global names are immutable in AWS, so they must be built from literal
|
|
13
|
+
* synth-time inputs only. A CDK token (or text that came from one, such as
|
|
14
|
+
* `token-token-17565`) makes the name change on every synth and fail with
|
|
15
|
+
* AlreadyExists, so this throws instead.
|
|
16
|
+
*/
|
|
17
|
+
export function assertLiteralResourceName(label, name) {
|
|
18
|
+
if (Token.isUnresolved(name) || /token|\$\{/i.test(name)) {
|
|
19
|
+
throw new Error(`${label} "${name}" is derived from an unresolved CDK token. Build account-global names from literal site config values only.`);
|
|
20
|
+
}
|
|
21
|
+
return name;
|
|
22
|
+
}
|
|
23
|
+
export function readBooleanEnvFlag(environment, name, defaultValue) {
|
|
24
|
+
const raw = (environment[name] ?? "").trim().toLowerCase();
|
|
25
|
+
if (["1", "true", "yes", "on"].includes(raw))
|
|
26
|
+
return true;
|
|
27
|
+
if (["0", "false", "no", "off"].includes(raw))
|
|
28
|
+
return false;
|
|
29
|
+
return defaultValue;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* True only for the Amplify app that owns the original p.apyr.us backend. That
|
|
33
|
+
* backend keeps its historical account-global names so its live resources are
|
|
34
|
+
* never replaced. Every other site gets brand-scoped names.
|
|
35
|
+
*/
|
|
36
|
+
export function isLegacyProductionApp(identity) {
|
|
37
|
+
return identity.productionAppId !== "" && identity.amplifyAppId === identity.productionAppId;
|
|
38
|
+
}
|
|
39
|
+
export function isLegacyProductionPipeline(identity) {
|
|
40
|
+
return isLegacyProductionApp(identity) && identity.amplifyBranch === "main";
|
|
41
|
+
}
|
|
42
|
+
export function resolveSiteBackendFeatureFlags(features, environment, identity) {
|
|
43
|
+
const legacyPipeline = isLegacyProductionPipeline(identity);
|
|
44
|
+
return {
|
|
45
|
+
consoleResponder: features?.consoleResponder ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_CONSOLE_RESPONDER", true),
|
|
46
|
+
inboundEmail: features?.inboundEmail ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_INBOUND_EMAIL", legacyPipeline),
|
|
47
|
+
slack: features?.slack ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_SLACK", false),
|
|
48
|
+
storageBackups: features?.storageBackups ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_STORAGE_BACKUPS", legacyPipeline),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function deriveKnowledgeVectorIndexName(identity) {
|
|
52
|
+
const name = isLegacyProductionApp(identity)
|
|
53
|
+
? "papyrus-knowledge"
|
|
54
|
+
: `papyrus-knowledge-${sanitizeAwsName(identity.brandId, 40)}`;
|
|
55
|
+
return assertLiteralResourceName("Knowledge vector index name", name);
|
|
56
|
+
}
|
|
57
|
+
export function deriveStorageBackupVaultName(identity, explicitName) {
|
|
58
|
+
const explicit = (explicitName ?? "").trim();
|
|
59
|
+
if (explicit !== "")
|
|
60
|
+
return assertLiteralResourceName("Backup vault name", sanitizeAwsName(explicit));
|
|
61
|
+
if (isLegacyProductionPipeline(identity)) {
|
|
62
|
+
return assertLiteralResourceName("Backup vault name", sanitizeAwsName(`papyrus-${identity.productionAppId}-main-media-backup-vault`));
|
|
63
|
+
}
|
|
64
|
+
const literalSiteKey = [identity.brandId, identity.amplifyAppId, identity.amplifyBranch].join("|");
|
|
65
|
+
const siteHash = createHash("sha256").update(literalSiteKey).digest("hex").slice(0, 8);
|
|
66
|
+
return assertLiteralResourceName("Backup vault name", `papyrus-${sanitizeAwsName(identity.brandId, 20)}-${siteHash}-media-vault`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The `papyrus-site-inbound-` prefix keeps a brand named `p-apyr-us` from
|
|
70
|
+
* colliding with the legacy `papyrus-inbound-p-apyr-us` set.
|
|
71
|
+
* SES allows one ACTIVE receipt rule set per region. A non-legacy site creates
|
|
72
|
+
* its own brand-named rule set and never activates it from CloudFormation.
|
|
73
|
+
*/
|
|
74
|
+
export function deriveReceiptRuleSetName(identity, inboundEmailDomain) {
|
|
75
|
+
const name = isLegacyProductionApp(identity)
|
|
76
|
+
? `papyrus-inbound-${inboundEmailDomain.replace(/\./g, "-")}`
|
|
77
|
+
: `papyrus-site-inbound-${sanitizeAwsName(identity.brandId, 40)}`;
|
|
78
|
+
return assertLiteralResourceName("Receipt rule set name", name);
|
|
79
|
+
}
|
|
80
|
+
/** `undefined` keeps the legacy auto-generated rule name of the live p.apyr.us rule. */
|
|
81
|
+
export function deriveReceiptRuleName(identity) {
|
|
82
|
+
if (isLegacyProductionApp(identity))
|
|
83
|
+
return undefined;
|
|
84
|
+
return assertLiteralResourceName("Receipt rule name", `${sanitizeAwsName(identity.brandId, 40)}-inbound-submissions`);
|
|
85
|
+
}
|
|
86
|
+
export function planInboundEmailSes(identity, inboundEmailEnabled, options) {
|
|
87
|
+
const legacyPipeline = isLegacyProductionPipeline(identity);
|
|
88
|
+
const deployedMainBranch = identity.amplifyAppId !== "" && identity.amplifyBranch === "main";
|
|
89
|
+
const createReceiptRules = inboundEmailEnabled && (legacyPipeline || deployedMainBranch);
|
|
90
|
+
return {
|
|
91
|
+
createReceiptRules,
|
|
92
|
+
activateReceiptRuleSet: createReceiptRules && legacyPipeline,
|
|
93
|
+
manageDomainIdentity: createReceiptRules && (legacyPipeline || options?.manageDomainIdentity === true),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function deriveSiteGlobalResourceNames(identity, inboundEmailDomain) {
|
|
97
|
+
return {
|
|
98
|
+
knowledgeVectorIndexName: deriveKnowledgeVectorIndexName(identity),
|
|
99
|
+
storageBackupVaultName: deriveStorageBackupVaultName(identity),
|
|
100
|
+
receiptRuleSetName: deriveReceiptRuleSetName(identity, inboundEmailDomain),
|
|
101
|
+
receiptRuleName: deriveReceiptRuleName(identity),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Token } from "aws-cdk-lib";
|
|
3
|
+
|
|
4
|
+
export type SiteBackendFeatureFlags = {
|
|
5
|
+
consoleResponder: boolean;
|
|
6
|
+
inboundEmail: boolean;
|
|
7
|
+
slack: boolean;
|
|
8
|
+
storageBackups: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type SiteBackendFeatureInput = Partial<SiteBackendFeatureFlags> | undefined;
|
|
12
|
+
|
|
13
|
+
export function sanitizeAwsName(value: string, maxLength = 50): string {
|
|
14
|
+
return value
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
17
|
+
.replace(/-+/g, "-")
|
|
18
|
+
.replace(/^-|-$/g, "")
|
|
19
|
+
.slice(0, maxLength);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Account-global names are immutable in AWS, so they must be built from literal
|
|
24
|
+
* synth-time inputs only. A CDK token (or text that came from one, such as
|
|
25
|
+
* `token-token-17565`) makes the name change on every synth and fail with
|
|
26
|
+
* AlreadyExists, so this throws instead.
|
|
27
|
+
*/
|
|
28
|
+
export function assertLiteralResourceName(label: string, name: string): string {
|
|
29
|
+
if (Token.isUnresolved(name) || /token|\$\{/i.test(name)) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`${label} "${name}" is derived from an unresolved CDK token. Build account-global names from literal site config values only.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readBooleanEnvFlag(
|
|
38
|
+
environment: Record<string, string | undefined>,
|
|
39
|
+
name: string,
|
|
40
|
+
defaultValue: boolean,
|
|
41
|
+
): boolean {
|
|
42
|
+
const raw = (environment[name] ?? "").trim().toLowerCase();
|
|
43
|
+
if (["1", "true", "yes", "on"].includes(raw)) return true;
|
|
44
|
+
if (["0", "false", "no", "off"].includes(raw)) return false;
|
|
45
|
+
return defaultValue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type SiteBackendIdentity = {
|
|
49
|
+
brandId: string;
|
|
50
|
+
amplifyAppId: string;
|
|
51
|
+
amplifyBranch: string;
|
|
52
|
+
productionAppId: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* True only for the Amplify app that owns the original p.apyr.us backend. That
|
|
57
|
+
* backend keeps its historical account-global names so its live resources are
|
|
58
|
+
* never replaced. Every other site gets brand-scoped names.
|
|
59
|
+
*/
|
|
60
|
+
export function isLegacyProductionApp(identity: SiteBackendIdentity): boolean {
|
|
61
|
+
return identity.productionAppId !== "" && identity.amplifyAppId === identity.productionAppId;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isLegacyProductionPipeline(identity: SiteBackendIdentity): boolean {
|
|
65
|
+
return isLegacyProductionApp(identity) && identity.amplifyBranch === "main";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resolveSiteBackendFeatureFlags(
|
|
69
|
+
features: SiteBackendFeatureInput,
|
|
70
|
+
environment: Record<string, string | undefined>,
|
|
71
|
+
identity: SiteBackendIdentity,
|
|
72
|
+
): SiteBackendFeatureFlags {
|
|
73
|
+
const legacyPipeline = isLegacyProductionPipeline(identity);
|
|
74
|
+
return {
|
|
75
|
+
consoleResponder:
|
|
76
|
+
features?.consoleResponder ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_CONSOLE_RESPONDER", true),
|
|
77
|
+
inboundEmail:
|
|
78
|
+
features?.inboundEmail ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_INBOUND_EMAIL", legacyPipeline),
|
|
79
|
+
slack: features?.slack ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_SLACK", false),
|
|
80
|
+
storageBackups:
|
|
81
|
+
features?.storageBackups ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_STORAGE_BACKUPS", legacyPipeline),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function deriveKnowledgeVectorIndexName(identity: SiteBackendIdentity): string {
|
|
86
|
+
const name = isLegacyProductionApp(identity)
|
|
87
|
+
? "papyrus-knowledge"
|
|
88
|
+
: `papyrus-knowledge-${sanitizeAwsName(identity.brandId, 40)}`;
|
|
89
|
+
return assertLiteralResourceName("Knowledge vector index name", name);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function deriveStorageBackupVaultName(identity: SiteBackendIdentity, explicitName?: string): string {
|
|
93
|
+
const explicit = (explicitName ?? "").trim();
|
|
94
|
+
if (explicit !== "") return assertLiteralResourceName("Backup vault name", sanitizeAwsName(explicit));
|
|
95
|
+
if (isLegacyProductionPipeline(identity)) {
|
|
96
|
+
return assertLiteralResourceName(
|
|
97
|
+
"Backup vault name",
|
|
98
|
+
sanitizeAwsName(`papyrus-${identity.productionAppId}-main-media-backup-vault`),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const literalSiteKey = [identity.brandId, identity.amplifyAppId, identity.amplifyBranch].join("|");
|
|
102
|
+
const siteHash = createHash("sha256").update(literalSiteKey).digest("hex").slice(0, 8);
|
|
103
|
+
return assertLiteralResourceName(
|
|
104
|
+
"Backup vault name",
|
|
105
|
+
`papyrus-${sanitizeAwsName(identity.brandId, 20)}-${siteHash}-media-vault`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The `papyrus-site-inbound-` prefix keeps a brand named `p-apyr-us` from
|
|
111
|
+
* colliding with the legacy `papyrus-inbound-p-apyr-us` set.
|
|
112
|
+
* SES allows one ACTIVE receipt rule set per region. A non-legacy site creates
|
|
113
|
+
* its own brand-named rule set and never activates it from CloudFormation.
|
|
114
|
+
*/
|
|
115
|
+
export function deriveReceiptRuleSetName(identity: SiteBackendIdentity, inboundEmailDomain: string): string {
|
|
116
|
+
const name = isLegacyProductionApp(identity)
|
|
117
|
+
? `papyrus-inbound-${inboundEmailDomain.replace(/\./g, "-")}`
|
|
118
|
+
: `papyrus-site-inbound-${sanitizeAwsName(identity.brandId, 40)}`;
|
|
119
|
+
return assertLiteralResourceName("Receipt rule set name", name);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `undefined` keeps the legacy auto-generated rule name of the live p.apyr.us rule. */
|
|
123
|
+
export function deriveReceiptRuleName(identity: SiteBackendIdentity): string | undefined {
|
|
124
|
+
if (isLegacyProductionApp(identity)) return undefined;
|
|
125
|
+
return assertLiteralResourceName("Receipt rule name", `${sanitizeAwsName(identity.brandId, 40)}-inbound-submissions`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type InboundEmailSesPlan = {
|
|
129
|
+
createReceiptRules: boolean;
|
|
130
|
+
activateReceiptRuleSet: boolean;
|
|
131
|
+
manageDomainIdentity: boolean;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export function planInboundEmailSes(
|
|
135
|
+
identity: SiteBackendIdentity,
|
|
136
|
+
inboundEmailEnabled: boolean,
|
|
137
|
+
options: { manageDomainIdentity?: boolean } | undefined,
|
|
138
|
+
): InboundEmailSesPlan {
|
|
139
|
+
const legacyPipeline = isLegacyProductionPipeline(identity);
|
|
140
|
+
const deployedMainBranch = identity.amplifyAppId !== "" && identity.amplifyBranch === "main";
|
|
141
|
+
const createReceiptRules = inboundEmailEnabled && (legacyPipeline || deployedMainBranch);
|
|
142
|
+
return {
|
|
143
|
+
createReceiptRules,
|
|
144
|
+
activateReceiptRuleSet: createReceiptRules && legacyPipeline,
|
|
145
|
+
manageDomainIdentity: createReceiptRules && (legacyPipeline || options?.manageDomainIdentity === true),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type SiteGlobalResourceNames = {
|
|
150
|
+
knowledgeVectorIndexName: string;
|
|
151
|
+
storageBackupVaultName: string;
|
|
152
|
+
receiptRuleSetName: string;
|
|
153
|
+
receiptRuleName: string | undefined;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export function deriveSiteGlobalResourceNames(
|
|
157
|
+
identity: SiteBackendIdentity,
|
|
158
|
+
inboundEmailDomain: string,
|
|
159
|
+
): SiteGlobalResourceNames {
|
|
160
|
+
return {
|
|
161
|
+
knowledgeVectorIndexName: deriveKnowledgeVectorIndexName(identity),
|
|
162
|
+
storageBackupVaultName: deriveStorageBackupVaultName(identity),
|
|
163
|
+
receiptRuleSetName: deriveReceiptRuleSetName(identity, inboundEmailDomain),
|
|
164
|
+
receiptRuleName: deriveReceiptRuleName(identity),
|
|
165
|
+
};
|
|
166
|
+
}
|