@anthusai/papyrus 1.0.0-next.16 → 1.0.0-next.18

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.
@@ -1,5 +1,6 @@
1
1
  import { defineAuth, secret } from "@aws-amplify/backend";
2
2
  import { manageUserRole } from "../functions/manage-user-role/resource.js";
3
+ import { CfnUserPoolDomain } from "aws-cdk-lib/aws-cognito";
3
4
  // Default OAuth redirect URLs for the p.apyr.us app. Other publications pass
4
5
  // `auth.redirectUrls` to `defineSiteBackend` (or, for Papyrus's own backend,
5
6
  // the PAPYRUS_OAUTH_REDIRECT_URLS branch env var, comma-separated).
@@ -25,15 +26,13 @@ export function authConfigFromEnv() {
25
26
  return {
26
27
  redirectUrls: resolveAuthRedirectUrls(),
27
28
  disableGoogleOAuth: process.env.PAPYRUS_DISABLE_GOOGLE_OAUTH === "1",
28
- // Stable Cognito hosted-UI domain prefix for Google OAuth redirect URIs.
29
- // See docs/google-oauth-setup.md.
30
29
  cognitoDomainPrefix: (process.env.PAPYRUS_COGNITO_DOMAIN_PREFIX ?? "").trim(),
30
+ applyCognitoDomainPrefix: process.env.PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX === "true",
31
31
  };
32
32
  }
33
33
  export function defineSiteAuth(config) {
34
34
  const authRedirectUrls = config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS;
35
35
  const disableGoogleOAuth = config.disableGoogleOAuth === true;
36
- const cognitoDomainPrefix = (config.cognitoDomainPrefix ?? "").trim();
37
36
  return defineAuth({
38
37
  loginWith: {
39
38
  email: true,
@@ -49,7 +48,6 @@ export function defineSiteAuth(config) {
49
48
  scopes: ["EMAIL", "PROFILE", "OPENID"],
50
49
  callbackUrls: authRedirectUrls,
51
50
  logoutUrls: authRedirectUrls,
52
- ...(cognitoDomainPrefix ? { domainPrefix: cognitoDomainPrefix } : {}),
53
51
  },
54
52
  }),
55
53
  },
@@ -59,3 +57,18 @@ export function defineSiteAuth(config) {
59
57
  ],
60
58
  });
61
59
  }
60
+ // Amplify Gen2's auth factory overwrites `externalProviders.domainPrefix` with a
61
+ // hash of the backend id, so the prefix cannot be passed through `defineAuth`.
62
+ // This sets the user pool domain on the synthesized resource instead. Cognito
63
+ // cannot rename a pool domain in place: CloudFormation replaces the domain,
64
+ // which changes the hosted-UI URL and the Google redirect URI. Callers must
65
+ // only use it when the site opted in (see docs/google-oauth-setup.md).
66
+ export function applyCognitoDomainPrefix(authStack, cognitoDomainPrefix) {
67
+ const domainResources = authStack.node
68
+ .findAll()
69
+ .filter((construct) => construct instanceof CfnUserPoolDomain);
70
+ if (domainResources.length !== 1) {
71
+ throw new Error(`Expected exactly one Cognito user pool domain in the auth stack to apply cognitoDomainPrefix, found ${domainResources.length}`);
72
+ }
73
+ domainResources[0].domain = cognitoDomainPrefix;
74
+ }
@@ -1,5 +1,7 @@
1
1
  import { defineAuth, secret } from "@aws-amplify/backend";
2
2
  import { manageUserRole } from "../functions/manage-user-role/resource";
3
+ import { Stack } from "aws-cdk-lib";
4
+ import { CfnUserPoolDomain } from "aws-cdk-lib/aws-cognito";
3
5
  import type { PapyrusAuthConfig } from "../site-backend-config";
4
6
 
5
7
  // Default OAuth redirect URLs for the p.apyr.us app. Other publications pass
@@ -28,16 +30,14 @@ export function authConfigFromEnv(): PapyrusAuthConfig {
28
30
  return {
29
31
  redirectUrls: resolveAuthRedirectUrls(),
30
32
  disableGoogleOAuth: process.env.PAPYRUS_DISABLE_GOOGLE_OAUTH === "1",
31
- // Stable Cognito hosted-UI domain prefix for Google OAuth redirect URIs.
32
- // See docs/google-oauth-setup.md.
33
33
  cognitoDomainPrefix: (process.env.PAPYRUS_COGNITO_DOMAIN_PREFIX ?? "").trim(),
34
+ applyCognitoDomainPrefix: process.env.PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX === "true",
34
35
  };
35
36
  }
36
37
 
37
38
  export function defineSiteAuth(config: PapyrusAuthConfig) {
38
39
  const authRedirectUrls = config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS;
39
40
  const disableGoogleOAuth = config.disableGoogleOAuth === true;
40
- const cognitoDomainPrefix = (config.cognitoDomainPrefix ?? "").trim();
41
41
  return defineAuth({
42
42
  loginWith: {
43
43
  email: true,
@@ -53,7 +53,6 @@ export function defineSiteAuth(config: PapyrusAuthConfig) {
53
53
  scopes: ["EMAIL", "PROFILE", "OPENID"],
54
54
  callbackUrls: authRedirectUrls,
55
55
  logoutUrls: authRedirectUrls,
56
- ...(cognitoDomainPrefix ? { domainPrefix: cognitoDomainPrefix } : {}),
57
56
  },
58
57
  }),
59
58
  },
@@ -63,3 +62,21 @@ export function defineSiteAuth(config: PapyrusAuthConfig) {
63
62
  ],
64
63
  });
65
64
  }
65
+
66
+ // Amplify Gen2's auth factory overwrites `externalProviders.domainPrefix` with a
67
+ // hash of the backend id, so the prefix cannot be passed through `defineAuth`.
68
+ // This sets the user pool domain on the synthesized resource instead. Cognito
69
+ // cannot rename a pool domain in place: CloudFormation replaces the domain,
70
+ // which changes the hosted-UI URL and the Google redirect URI. Callers must
71
+ // only use it when the site opted in (see docs/google-oauth-setup.md).
72
+ export function applyCognitoDomainPrefix(authStack: Stack, cognitoDomainPrefix: string): void {
73
+ const domainResources = authStack.node
74
+ .findAll()
75
+ .filter((construct): construct is CfnUserPoolDomain => construct instanceof CfnUserPoolDomain);
76
+ if (domainResources.length !== 1) {
77
+ throw new Error(
78
+ `Expected exactly one Cognito user pool domain in the auth stack to apply cognitoDomainPrefix, found ${domainResources.length}`,
79
+ );
80
+ }
81
+ domainResources[0].domain = cognitoDomainPrefix;
82
+ }
@@ -7,8 +7,13 @@ export type PapyrusAuthConfig = {
7
7
  /** OAuth callback/logout URLs for Google sign-in (ignored when Google is disabled). */
8
8
  redirectUrls?: string[];
9
9
  disableGoogleOAuth?: boolean;
10
- /** Stable Cognito hosted-UI domain prefix. */
10
+ /** Desired Cognito hosted-UI domain prefix. Only applied when `applyCognitoDomainPrefix` is true. */
11
11
  cognitoDomainPrefix?: string;
12
+ /**
13
+ * Opt in to the prefix. Off by default because changing a live pool's
14
+ * domain replaces it and changes the Google redirect URI.
15
+ */
16
+ applyCognitoDomainPrefix?: boolean;
12
17
  };
13
18
 
14
19
  export type PapyrusSiteBackendConfig = {
@@ -13,7 +13,7 @@ import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aw
13
13
  import { dirname, resolve } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
16
- import { authConfigFromEnv, defineSiteAuth } from "./auth/resource.js";
16
+ import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource.js";
17
17
  import { data } from "./data/resource.js";
18
18
  import { assignmentAction } from "./functions/assignment-action/resource.js";
19
19
  import { categoryAction } from "./functions/category-action/resource.js";
@@ -76,9 +76,10 @@ export function defineSiteBackend(papyrusSite) {
76
76
  const enableInboundEmail = site.features?.inboundEmail ?? readEnvFlag("PAPYRUS_ENABLE_INBOUND_EMAIL", isAmplifyProductionPipeline);
77
77
  const enableSlackAgent = site.features?.slack ?? readEnvFlag("PAPYRUS_ENABLE_SLACK", false);
78
78
  const enableStorageBackups = site.features?.storageBackups ?? readEnvFlag("PAPYRUS_ENABLE_STORAGE_BACKUPS", isAmplifyProductionPipeline);
79
+ const authConfig = site.auth ?? authConfigFromEnv();
79
80
  const backend = defineBackend({
80
81
  assignmentAction,
81
- auth: defineSiteAuth(site.auth ?? authConfigFromEnv()),
82
+ auth: defineSiteAuth(authConfig),
82
83
  categoryAction,
83
84
  contentActions,
84
85
  data,
@@ -94,6 +95,12 @@ export function defineSiteBackend(papyrusSite) {
94
95
  slackDelivery,
95
96
  storage,
96
97
  });
98
+ const requestedCognitoDomainPrefix = (authConfig.cognitoDomainPrefix ?? "").trim();
99
+ if (authConfig.applyCognitoDomainPrefix === true
100
+ && requestedCognitoDomainPrefix !== ""
101
+ && authConfig.disableGoogleOAuth !== true) {
102
+ applyCognitoDomainPrefix(backend.auth.stack, requestedCognitoDomainPrefix);
103
+ }
97
104
  const amplifyBackendDir = dirname(fileURLToPath(import.meta.url));
98
105
  // Repo root in a Papyrus checkout; package root when installed from npm.
99
106
  const projectRoot = resolve(amplifyBackendDir, "..");
@@ -14,7 +14,7 @@ import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aw
14
14
  import { dirname, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
17
- import { authConfigFromEnv, defineSiteAuth } from "./auth/resource";
17
+ import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource";
18
18
  import { data } from "./data/resource";
19
19
  import { assignmentAction } from "./functions/assignment-action/resource";
20
20
  import { categoryAction } from "./functions/category-action/resource";
@@ -87,9 +87,10 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
87
87
  const enableSlackAgent = site.features?.slack ?? readEnvFlag("PAPYRUS_ENABLE_SLACK", false);
88
88
  const enableStorageBackups = site.features?.storageBackups ?? readEnvFlag("PAPYRUS_ENABLE_STORAGE_BACKUPS", isAmplifyProductionPipeline);
89
89
 
90
+ const authConfig = site.auth ?? authConfigFromEnv();
90
91
  const backend = defineBackend({
91
92
  assignmentAction,
92
- auth: defineSiteAuth(site.auth ?? authConfigFromEnv()),
93
+ auth: defineSiteAuth(authConfig),
93
94
  categoryAction,
94
95
  contentActions,
95
96
  data,
@@ -106,6 +107,15 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
106
107
  storage,
107
108
  });
108
109
 
110
+ const requestedCognitoDomainPrefix = (authConfig.cognitoDomainPrefix ?? "").trim();
111
+ if (
112
+ authConfig.applyCognitoDomainPrefix === true
113
+ && requestedCognitoDomainPrefix !== ""
114
+ && authConfig.disableGoogleOAuth !== true
115
+ ) {
116
+ applyCognitoDomainPrefix(backend.auth.stack, requestedCognitoDomainPrefix);
117
+ }
118
+
109
119
  const amplifyBackendDir = dirname(fileURLToPath(import.meta.url));
110
120
  // Repo root in a Papyrus checkout; package root when installed from npm.
111
121
  const projectRoot = resolve(amplifyBackendDir, "..");
@@ -1,3 +1,4 @@
1
+ import { toPublicReaderPath } from "../../../lib/reader-base-path";
1
2
  import { revalidatePath, revalidateTag } from "next/cache";
2
3
  import { NextResponse } from "next/server";
3
4
  import {
@@ -44,8 +45,8 @@ export async function POST(request: Request) {
44
45
  if (!normalized) continue;
45
46
  revalidateTag(articleCacheTag(normalized));
46
47
  revalidatedTags.add(articleCacheTag(normalized));
47
- revalidatePath(`/articles/${encodeURIComponent(normalized)}`);
48
- revalidatedPaths.add(`/articles/${encodeURIComponent(normalized)}`);
48
+ revalidatePath(toPublicReaderPath(`/articles/${encodeURIComponent(normalized)}`));
49
+ revalidatedPaths.add(toPublicReaderPath(`/articles/${encodeURIComponent(normalized)}`));
49
50
  if (editionDate) {
50
51
  revalidateTag(editionItemCacheTag(editionDate, normalized));
51
52
  revalidatedTags.add(editionItemCacheTag(editionDate, normalized));
@@ -76,8 +77,8 @@ export async function POST(request: Request) {
76
77
  revalidatedTags.add(ARCHIVE_CACHE_TAG);
77
78
  revalidateTag(ARTICLES_CACHE_TAG);
78
79
  revalidatedTags.add(ARTICLES_CACHE_TAG);
79
- revalidatePath("/archive");
80
- revalidatedPaths.add("/archive");
80
+ revalidatePath(toPublicReaderPath("/archive"));
81
+ revalidatedPaths.add(toPublicReaderPath("/archive"));
81
82
 
82
83
  for (const path of body.paths ?? []) {
83
84
  const normalized = path.trim();
package/app/page.tsx CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  import type { PublicationItem } from "../lib/publication-items";
17
17
  import { SITE_BRAND, rootRoute } from "../lib/site-brand";
18
18
  import { notFound, redirect } from "next/navigation";
19
+ import { getReaderBasePath } from "../lib/reader-base-path";
19
20
  import { buildRootRedirectTarget } from "../lib/root-route-redirect";
20
21
  import NewsDeskRootPage, { type NewsDeskRootSearchParams } from "./newsroom/page";
21
22
 
@@ -38,11 +39,13 @@ export default async function Home({ searchParams }: HomePageProps) {
38
39
  // co-hosts reader + CMS uses the default "reader" (render the publication
39
40
  // home page here). A CMS-only site points the root at the newsroom or
40
41
  // anywhere else. Preserve the scenario and OAuth-callback paths.
41
- if (rootRoute.kind === "newsroom" && !scenarioId) {
42
+ const publicationOwnsRoot = getReaderBasePath() !== "";
43
+
44
+ if (!publicationOwnsRoot && rootRoute.kind === "newsroom" && !scenarioId) {
42
45
  return <NewsDeskRootPage searchParams={searchParams} />;
43
46
  }
44
47
 
45
- if (rootRoute.kind === "redirect") {
48
+ if (!publicationOwnsRoot && rootRoute.kind === "redirect") {
46
49
  redirect(buildRootRedirectTarget(rootRoute.destination, resolvedSearchParams));
47
50
  }
48
51
 
@@ -12,6 +12,7 @@ import { ARCHIVE_PREVIEW_HEIGHT, ARCHIVE_PREVIEW_WIDTH } from "../lib/archive-ty
12
12
  import type { EditionContent, NewsDeskAppendix, NewsDeskCategoryTreeNode } from "../lib/content-types";
13
13
  import { shouldBypassImageOptimization } from "../lib/image-url";
14
14
  import type { PublicationItem } from "../lib/publication-items";
15
+ import { toPublicReaderPath } from "../lib/reader-base-path";
15
16
  import { SITE_BRAND } from "../lib/site-brand";
16
17
  import { resolveThemedImageSrc, type ResolvedTheme } from "../lib/themed-image";
17
18
  import { loadEditorCategoryTreeState } from "./news-desk-taxonomy-client";
@@ -1877,7 +1878,7 @@ function getPageHref(pageNumber: number, editionBasePath?: string): string {
1877
1878
  }
1878
1879
 
1879
1880
  function getArticleHref(articleSlug: string, editionBasePath?: string): string {
1880
- return editionBasePath ? `${normalizePath(editionBasePath)}/${encodeURIComponent(articleSlug)}` : `/articles/${articleSlug}`;
1881
+ return editionBasePath ? `${normalizePath(editionBasePath)}/${encodeURIComponent(articleSlug)}` : toPublicReaderPath(`/articles/${articleSlug}`);
1881
1882
  }
1882
1883
 
1883
1884
  function getItemAnchorHref(articleSlug: string, pageNumber: number, editionBasePath?: string): string {
@@ -3,8 +3,13 @@
3
3
  import {
4
4
  BookOpenIcon,
5
5
  ClipboardListIcon,
6
+ FileTextIcon,
6
7
  LayersIcon,
8
+ LightbulbIcon,
7
9
  MessageSquareIcon,
10
+ NetworkIcon,
11
+ SearchIcon,
12
+ SettingsIcon,
8
13
  } from "lucide-react";
9
14
  import Link from "next/link";
10
15
  import { usePathname } from "next/navigation";
@@ -12,7 +17,7 @@ import { useMemo } from "react";
12
17
  import { Badge } from "./ui/badge";
13
18
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
14
19
  import type { AssignmentRecord, MessageRecord, NewsroomSectionRecord, ReferenceRecord } from "../lib/category-repository";
15
- import { getNewsroomNavHref } from "../lib/newsroom-nav";
20
+ import { getNewsroomNavHref, NEWSROOM_OPS_NAV, type NewsroomNavTabId } from "../lib/newsroom-nav";
16
21
  import {
17
22
  countReferencesByStatus,
18
23
  filterReferencesByStatus,
@@ -37,15 +42,28 @@ type NewsroomOpsOverviewProps = {
37
42
  references: ReferenceRecord[];
38
43
  };
39
44
 
40
- type DestinationKey = "references" | "assignments" | "messages" | "topics";
45
+ type DestinationKey = Exclude<NewsroomNavTabId, "overview" | "desks">;
41
46
 
42
- const DESTINATION_META: Record<DestinationKey, { label: string; detail: string; href: string; icon: typeof BookOpenIcon }> = {
43
- references: { label: "References", detail: "Source intake", href: "/newsroom/references", icon: BookOpenIcon },
44
- assignments: { label: "Assignments", detail: "Work queue", href: "/newsroom/assignments", icon: ClipboardListIcon },
45
- messages: { label: "Messages", detail: "Team threads", href: "/newsroom/messages", icon: MessageSquareIcon },
46
- topics: { label: "Topics", detail: "Taxonomy", href: "/newsroom/topics", icon: LayersIcon },
47
+ const DESTINATION_ICONS: Record<DestinationKey, typeof BookOpenIcon> = {
48
+ articles: FileTextIcon,
49
+ assignments: ClipboardListIcon,
50
+ references: BookOpenIcon,
51
+ messages: MessageSquareIcon,
52
+ insights: LightbulbIcon,
53
+ topics: LayersIcon,
54
+ concepts: NetworkIcon,
55
+ administration: SettingsIcon,
56
+ search: SearchIcon,
47
57
  };
48
58
 
59
+ const DESTINATIONS = NEWSROOM_OPS_NAV.filter((item) => item.id !== "overview").map((item) => ({
60
+ key: item.id as DestinationKey,
61
+ label: item.label,
62
+ detail: item.detail,
63
+ href: item.href,
64
+ icon: DESTINATION_ICONS[item.id as DestinationKey],
65
+ }));
66
+
49
67
  function openAssignmentCount(assignments: AssignmentRecord[]): number {
50
68
  return assignments.filter((assignment) => {
51
69
  const status = assignment.status?.trim().toLowerCase() ?? "";
@@ -85,11 +103,10 @@ export function NewsroomOpsOverview({
85
103
  [newsroomSections],
86
104
  );
87
105
 
88
- const destinationCounts: Record<DestinationKey, number | null> = {
106
+ const destinationCounts: Partial<Record<DestinationKey, number | null>> = {
89
107
  references: referenceCounts.pending ?? 0,
90
108
  assignments: openAssignmentCount(assignments),
91
109
  messages: recentMessageCount(messages),
92
- topics: null,
93
110
  };
94
111
 
95
112
  return (
@@ -102,8 +119,8 @@ export function NewsroomOpsOverview({
102
119
  <section aria-label="Destinations" className="space-y-3">
103
120
  <h2 className="text-sm font-medium text-muted-foreground">Go to</h2>
104
121
  <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
105
- {(Object.keys(DESTINATION_META) as DestinationKey[]).map((key) => {
106
- const meta = DESTINATION_META[key];
122
+ {DESTINATIONS.map((meta) => {
123
+ const key = meta.key;
107
124
  const Icon = meta.icon;
108
125
  const count = destinationCounts[key];
109
126
  const href = getNewsroomNavHref(meta.href, demo);
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { toPublicReaderPath } from "../lib/reader-base-path";
3
4
  import { signOut } from "aws-amplify/auth";
4
5
  import Link from "next/link";
5
6
  import { useCallback, useEffect, useRef, useState } from "react";
@@ -114,7 +115,7 @@ export function SettingsPage() {
114
115
  return (
115
116
  <main className="settings-page">
116
117
  <nav className="edition-progress edition-progress--newsroom settings-progress" aria-label="Settings navigation">
117
- <Link className="edition-progress__button edition-progress__button--previous" href="/">
118
+ <Link className="edition-progress__button edition-progress__button--previous" href={toPublicReaderPath("/")}>
118
119
  <svg aria-hidden="true" className="edition-progress__icon" focusable="false" viewBox="0 0 10 10">
119
120
  <path d="M7.5 1 2.5 5 7.5 9Z" fill="currentColor" />
120
121
  </svg>
@@ -3,7 +3,7 @@ import * as iam from "aws-cdk-lib/aws-iam";
3
3
  import * as amplify from "aws-cdk-lib/aws-amplify";
4
4
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
5
5
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
6
- import { isStagingEnabled, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
6
+ import { isStagingEnabled, resolveCmsHostName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
7
7
  function environmentVariables(variables) {
8
8
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
9
9
  }
@@ -20,8 +20,9 @@ function environmentVariables(variables) {
20
20
  * - `cms.staging: false` omits the staging branch and its domain
21
21
  *
22
22
  * Google sign-in is always on: the branches never set PAPYRUS_DISABLE_GOOGLE_OAUTH,
23
- and `cms.cognitoDomainPrefix` becomes the stable hosted-UI domain
24
- (PAPYRUS_COGNITO_DOMAIN_PREFIX). The secrets GOOGLE_CLIENT_ID and
23
+ and `cms.cognitoDomainPrefix` becomes the hosted-UI domain
24
+ (PAPYRUS_COGNITO_DOMAIN_PREFIX) only when `cms.applyCognitoDomainPrefix` is true
25
+ (PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX); otherwise Amplify's generated domain is kept. The secrets GOOGLE_CLIENT_ID and
25
26
  GOOGLE_CLIENT_SECRET are Amplify backend secrets set in the console, never here.
26
27
  The stack also creates the `<siteId>-papyrus-authoring` role. The CLI signs
27
28
  * AppSync requests (SigV4) with credentials for that role (SSO, OIDC or any
@@ -40,6 +41,7 @@ export class AmplifyAppShellStack extends Stack {
40
41
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
41
42
  const stagingEnabled = isStagingEnabled(config);
42
43
  const stagingDomainName = resolveStagingDomainName(config);
44
+ const cmsHostName = resolveCmsHostName(config);
43
45
  const cmsServiceRole = new iam.Role(this, "AmplifyServiceRole", {
44
46
  assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
45
47
  description: `Backend deploy and staging build service role for Amplify app ${cmsAppName} (AdministratorAccess: documented risk)`,
@@ -66,12 +68,13 @@ export class AmplifyAppShellStack extends Stack {
66
68
  const stagingMode = config.frontend === "markus-static"
67
69
  ? { PAPYRUS_STAGING_PREVIEW: "static" }
68
70
  : { PAPYRUS_CONTENT_SOURCE: "drafts" };
69
- const productionOrigin = config.cms.domainName
70
- ? `https://${config.cms.domainName}/`
71
+ const productionOrigin = cmsHostName
72
+ ? `https://${cmsHostName}/`
71
73
  : Fn.join("", ["https://main.", cmsApp.attrDefaultDomain, "/"]);
72
74
  const stagingOrigin = stagingDomainName
73
75
  ? `https://${stagingDomainName}/`
74
76
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
77
+ const sharedRootDomain = config.cms.domainPrefix !== undefined;
75
78
  const defaultOriginsToAllow = [
76
79
  ...(config.cms.domainName ? [] : [productionOrigin]),
77
80
  ...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
@@ -88,14 +91,16 @@ export class AmplifyAppShellStack extends Stack {
88
91
  environmentVariables: environmentVariables({
89
92
  ...config.cms.environment,
90
93
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
94
+ ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
91
95
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
92
96
  PAPYRUS_SITE_BRAND: config.brand,
93
97
  SITE_ENV: "production",
94
98
  PAPYRUS_CONTENT_SOURCE: "published",
95
99
  }),
96
100
  });
101
+ let stagingBranch;
97
102
  if (stagingEnabled) {
98
- const stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
103
+ stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
99
104
  appId: cmsApp.attrAppId,
100
105
  branchName: "staging",
101
106
  stage: "BETA",
@@ -105,6 +110,7 @@ export class AmplifyAppShellStack extends Stack {
105
110
  environmentVariables: environmentVariables({
106
111
  ...config.cms.environment,
107
112
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
113
+ ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
108
114
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
109
115
  PAPYRUS_SITE_BRAND: config.brand,
110
116
  SITE_ENV: "staging",
@@ -114,7 +120,7 @@ export class AmplifyAppShellStack extends Stack {
114
120
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]),
115
121
  }),
116
122
  });
117
- if (stagingDomainName) {
123
+ if (stagingDomainName && !sharedRootDomain) {
118
124
  const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
119
125
  appId: cmsApp.attrAppId,
120
126
  domainName: stagingDomainName,
@@ -124,12 +130,21 @@ export class AmplifyAppShellStack extends Stack {
124
130
  }
125
131
  }
126
132
  if (config.cms.domainName) {
133
+ const subDomainSettings = sharedRootDomain
134
+ ? [
135
+ { branchName: "main", prefix: config.cms.domainPrefix },
136
+ ...(stagingBranch ? [{ branchName: "staging", prefix: config.cms.stagingDomainPrefix }] : []),
137
+ ]
138
+ : [{ branchName: "main", prefix: "" }];
127
139
  const productionDomain = new amplify.CfnDomain(this, "Domain", {
128
140
  appId: cmsApp.attrAppId,
129
141
  domainName: config.cms.domainName,
130
- subDomainSettings: [{ branchName: "main", prefix: "" }],
142
+ subDomainSettings,
131
143
  });
132
144
  productionDomain.addResourceDependency(productionBranch);
145
+ if (sharedRootDomain && stagingBranch) {
146
+ productionDomain.addResourceDependency(stagingBranch);
147
+ }
133
148
  }
134
149
  this.cmsAppId = cmsApp.attrAppId;
135
150
  new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
@@ -4,7 +4,7 @@ import * as iam from "aws-cdk-lib/aws-iam";
4
4
  import * as amplify from "aws-cdk-lib/aws-amplify";
5
5
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
6
6
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
7
- import { AmplifyAppShellSiteConfig, isStagingEnabled, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
7
+ import { AmplifyAppShellSiteConfig, isStagingEnabled, resolveCmsHostName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
8
8
 
9
9
  function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
10
10
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
@@ -23,8 +23,9 @@ function environmentVariables(variables: Record<string, string>): amplify.CfnBra
23
23
  * - `cms.staging: false` omits the staging branch and its domain
24
24
  *
25
25
  * Google sign-in is always on: the branches never set PAPYRUS_DISABLE_GOOGLE_OAUTH,
26
- and `cms.cognitoDomainPrefix` becomes the stable hosted-UI domain
27
- (PAPYRUS_COGNITO_DOMAIN_PREFIX). The secrets GOOGLE_CLIENT_ID and
26
+ and `cms.cognitoDomainPrefix` becomes the hosted-UI domain
27
+ (PAPYRUS_COGNITO_DOMAIN_PREFIX) only when `cms.applyCognitoDomainPrefix` is true
28
+ (PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX); otherwise Amplify's generated domain is kept. The secrets GOOGLE_CLIENT_ID and
28
29
  GOOGLE_CLIENT_SECRET are Amplify backend secrets set in the console, never here.
29
30
  The stack also creates the `<siteId>-papyrus-authoring` role. The CLI signs
30
31
  * AppSync requests (SigV4) with credentials for that role (SSO, OIDC or any
@@ -45,6 +46,7 @@ export class AmplifyAppShellStack extends Stack {
45
46
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
46
47
  const stagingEnabled = isStagingEnabled(config);
47
48
  const stagingDomainName = resolveStagingDomainName(config);
49
+ const cmsHostName = resolveCmsHostName(config);
48
50
 
49
51
  const cmsServiceRole = new iam.Role(this, "AmplifyServiceRole", {
50
52
  assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
@@ -76,12 +78,13 @@ export class AmplifyAppShellStack extends Stack {
76
78
  ? { PAPYRUS_STAGING_PREVIEW: "static" }
77
79
  : { PAPYRUS_CONTENT_SOURCE: "drafts" };
78
80
 
79
- const productionOrigin = config.cms.domainName
80
- ? `https://${config.cms.domainName}/`
81
+ const productionOrigin = cmsHostName
82
+ ? `https://${cmsHostName}/`
81
83
  : Fn.join("", ["https://main.", cmsApp.attrDefaultDomain, "/"]);
82
84
  const stagingOrigin = stagingDomainName
83
85
  ? `https://${stagingDomainName}/`
84
86
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
87
+ const sharedRootDomain = config.cms.domainPrefix !== undefined;
85
88
  const defaultOriginsToAllow = [
86
89
  ...(config.cms.domainName ? [] : [productionOrigin]),
87
90
  ...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
@@ -99,6 +102,7 @@ export class AmplifyAppShellStack extends Stack {
99
102
  environmentVariables: environmentVariables({
100
103
  ...config.cms.environment,
101
104
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
105
+ ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
102
106
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
103
107
  PAPYRUS_SITE_BRAND: config.brand,
104
108
  SITE_ENV: "production",
@@ -106,8 +110,9 @@ export class AmplifyAppShellStack extends Stack {
106
110
  }),
107
111
  });
108
112
 
113
+ let stagingBranch: amplify.CfnBranch | undefined;
109
114
  if (stagingEnabled) {
110
- const stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
115
+ stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
111
116
  appId: cmsApp.attrAppId,
112
117
  branchName: "staging",
113
118
  stage: "BETA",
@@ -117,6 +122,7 @@ export class AmplifyAppShellStack extends Stack {
117
122
  environmentVariables: environmentVariables({
118
123
  ...config.cms.environment,
119
124
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
125
+ ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
120
126
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
121
127
  PAPYRUS_SITE_BRAND: config.brand,
122
128
  SITE_ENV: "staging",
@@ -127,7 +133,7 @@ export class AmplifyAppShellStack extends Stack {
127
133
  }),
128
134
  });
129
135
 
130
- if (stagingDomainName) {
136
+ if (stagingDomainName && !sharedRootDomain) {
131
137
  const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
132
138
  appId: cmsApp.attrAppId,
133
139
  domainName: stagingDomainName,
@@ -138,12 +144,21 @@ export class AmplifyAppShellStack extends Stack {
138
144
  }
139
145
 
140
146
  if (config.cms.domainName) {
147
+ const subDomainSettings = sharedRootDomain
148
+ ? [
149
+ { branchName: "main", prefix: config.cms.domainPrefix as string },
150
+ ...(stagingBranch ? [{ branchName: "staging", prefix: config.cms.stagingDomainPrefix as string }] : []),
151
+ ]
152
+ : [{ branchName: "main", prefix: "" }];
141
153
  const productionDomain = new amplify.CfnDomain(this, "Domain", {
142
154
  appId: cmsApp.attrAppId,
143
155
  domainName: config.cms.domainName,
144
- subDomainSettings: [{ branchName: "main", prefix: "" }],
156
+ subDomainSettings,
145
157
  });
146
158
  productionDomain.addResourceDependency(productionBranch);
159
+ if (sharedRootDomain && stagingBranch) {
160
+ productionDomain.addResourceDependency(stagingBranch);
161
+ }
147
162
  }
148
163
 
149
164
  this.cmsAppId = cmsApp.attrAppId;
@@ -14,6 +14,7 @@ function commandLines(commands, indent) {
14
14
  }
15
15
  const NPM_CLEAN_INSTALL = "npm ci --cache .npm --prefer-offline";
16
16
  const FRONTEND_ENVIRONMENT_CAPTURE = "env | grep -e '^PAPYRUS_' -e '^SITE_ENV=' -e '^NEXT_PUBLIC_' >> .env.production";
17
+ const CHECKOUT_AS_PAPYRUS_ROOT = "PAPYRUS_ROOT=\"$PWD\"";
17
18
  function pythonProvisioningCommands(config) {
18
19
  return [
19
20
  "export UV_CACHE_DIR=\"$PWD/.uv-cache\"",
@@ -58,7 +59,7 @@ export function cmsStagingBuildSpec(config) {
58
59
  const cachePaths = [".next/cache/**/*", ".npm/**/*"];
59
60
  if (config.frontend === "markus-static") {
60
61
  const readerConfig = reader(config);
61
- preBuild.push(...pythonProvisioningCommands(config), "papyrus ops content export-published --drafts --out content-export --clean", readerConfig.buildCommand, `papyrus ops content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`);
62
+ preBuild.push(...pythonProvisioningCommands(config), `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content export-published --drafts --out content-export --clean`, readerConfig.buildCommand, `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`);
62
63
  cachePaths.push(".uv-cache/**/*");
63
64
  }
64
65
  return [
@@ -22,6 +22,8 @@ const NPM_CLEAN_INSTALL = "npm ci --cache .npm --prefer-offline";
22
22
  const FRONTEND_ENVIRONMENT_CAPTURE =
23
23
  "env | grep -e '^PAPYRUS_' -e '^SITE_ENV=' -e '^NEXT_PUBLIC_' >> .env.production";
24
24
 
25
+ const CHECKOUT_AS_PAPYRUS_ROOT = "PAPYRUS_ROOT=\"$PWD\"";
26
+
25
27
  function pythonProvisioningCommands(config: AmplifyAppShellSiteConfig): string[] {
26
28
  return [
27
29
  "export UV_CACHE_DIR=\"$PWD/.uv-cache\"",
@@ -90,9 +92,9 @@ export function cmsStagingBuildSpec(config: AmplifyAppShellSiteConfig): string {
90
92
  const readerConfig = reader(config);
91
93
  preBuild.push(
92
94
  ...pythonProvisioningCommands(config),
93
- "papyrus ops content export-published --drafts --out content-export --clean",
95
+ `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content export-published --drafts --out content-export --clean`,
94
96
  readerConfig.buildCommand,
95
- `papyrus ops content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`,
97
+ `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`,
96
98
  );
97
99
  cachePaths.push(".uv-cache/**/*");
98
100
  }