@anthusai/papyrus 1.0.0-next.4 → 1.0.0-next.6

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.
Files changed (51) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/content-actions/handler.py +44 -2
  6. package/amplify/rebuild-trigger-settings.js +22 -0
  7. package/amplify/rebuild-trigger-settings.ts +42 -0
  8. package/amplify/site-backend-config.ts +6 -0
  9. package/amplify/site-backend.js +18 -0
  10. package/amplify/site-backend.ts +20 -0
  11. package/amplify/storage/resource.js +3 -0
  12. package/amplify/storage/resource.ts +3 -0
  13. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  14. package/app/globals.css +19 -0
  15. package/app/newsroom/articles/[id]/page.tsx +15 -0
  16. package/app/newsroom/articles/page.tsx +13 -0
  17. package/bin/papyrus-app.mjs +12 -2
  18. package/bin/papyrus-infra.mjs +26 -0
  19. package/components/article-video.tsx +112 -3
  20. package/components/markus-ir-preview.tsx +279 -0
  21. package/components/newsroom-access-gate.tsx +66 -0
  22. package/components/newsroom-article-editor.tsx +637 -0
  23. package/components/newsroom-articles-view.tsx +129 -0
  24. package/components/newsroom-ops-shell.tsx +3 -1
  25. package/components/topic-steering-workspace.tsx +1 -59
  26. package/infra/amplify-app-shell.js +56 -1
  27. package/infra/amplify-app-shell.ts +79 -1
  28. package/infra/github-oidc-provider.js +24 -0
  29. package/infra/github-oidc-provider.ts +30 -0
  30. package/infra/index.js +1 -0
  31. package/infra/site-config.js +31 -1
  32. package/infra/site-config.ts +38 -1
  33. package/lib/content-actions-demo.ts +172 -0
  34. package/lib/newsroom-articles.ts +325 -0
  35. package/lib/newsroom-demo-articles.ts +90 -0
  36. package/lib/newsroom-nav.ts +2 -0
  37. package/lib/site-brand.ts +13 -0
  38. package/lib/staging-preview-object.ts +98 -0
  39. package/lib/video-mode.ts +42 -0
  40. package/lib/video-script.ts +63 -0
  41. package/middleware.ts +14 -1
  42. package/package.json +16 -1
  43. package/routes.manifest.json +27 -1
  44. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  45. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  46. package/scripts/videoml/bundle-common.mjs +129 -0
  47. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  48. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  49. package/scripts/videoml/preview/preview-timing.ts +42 -0
  50. package/scripts/videoml/preview.html +77 -0
  51. package/scripts/videoml/standard-entry.tsx +46 -0
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ import { PlusIcon } from "lucide-react";
4
+ import Link from "next/link";
5
+ import { useEffect, useMemo, useState } from "react";
6
+ import { Badge } from "./ui/badge";
7
+ import { buttonVariants } from "./ui/button";
8
+ import { Spinner } from "./ui/spinner";
9
+ import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
10
+ import { SITE_BRAND } from "../lib/site-brand";
11
+ import { getNewsroomNavHref } from "../lib/newsroom-nav";
12
+ import { newsroomHref } from "../lib/newsroom-base-path";
13
+ import {
14
+ resolveArticlesBackend,
15
+ rowStatusLabel,
16
+ type NewsroomArticleRow,
17
+ } from "../lib/newsroom-articles";
18
+ import { cn } from "../lib/utils";
19
+ import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
20
+ import { NewsDeskAccessGate } from "./newsroom-access-gate";
21
+ import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
22
+
23
+ type ArticleFilter = "all" | "drafts" | "published";
24
+
25
+ export function articlesAccessBlocked(phase: string | undefined): boolean {
26
+ return phase === undefined || phase === "checkingAccess" || phase === "signedOut" || phase === "forbidden" || phase === "error";
27
+ }
28
+
29
+ export function NewsroomArticlesView({ demo }: { demo: boolean }) {
30
+ const session = useOptionalNewsDeskClient();
31
+ const blocked = !demo && articlesAccessBlocked(session?.shell.phase);
32
+ const [rows, setRows] = useState<NewsroomArticleRow[] | null>(null);
33
+ const [loadError, setLoadError] = useState<string | null>(null);
34
+ const [filter, setFilter] = useState<ArticleFilter>("all");
35
+
36
+ useEffect(() => {
37
+ if (blocked) return;
38
+ let cancelled = false;
39
+ resolveArticlesBackend(demo)
40
+ .listRows()
41
+ .then((loaded) => {
42
+ if (!cancelled) setRows(loaded);
43
+ })
44
+ .catch((error: unknown) => {
45
+ if (!cancelled) setLoadError(error instanceof Error ? error.message : "Could not load articles.");
46
+ });
47
+ return () => {
48
+ cancelled = true;
49
+ };
50
+ }, [blocked, demo]);
51
+
52
+ const visibleRows = useMemo(() => {
53
+ if (!rows) return [];
54
+ if (filter === "drafts") return rows.filter((row) => row.status === "draft");
55
+ if (filter === "published") return rows.filter((row) => row.status === "published");
56
+ return rows;
57
+ }, [filter, rows]);
58
+
59
+ if (blocked) return <NewsDeskAccessGate shell={session?.shell ?? null} />;
60
+
61
+ return (
62
+ <NewsroomOpsShell
63
+ activeTab="articles"
64
+ appTitle={SITE_BRAND.appTitle}
65
+ backHref="/"
66
+ backLabel={SITE_BRAND.backToHomeLabel}
67
+ demo={demo}
68
+ headerActions={(
69
+ <>
70
+ {demo ? <Badge variant="outline">Demo</Badge> : null}
71
+ <Link
72
+ className={cn(buttonVariants({ size: "sm" }))}
73
+ data-newsroom-article-new
74
+ href={getNewsroomNavHref(newsroomHref("articles", "new"), demo)}
75
+ >
76
+ <PlusIcon aria-hidden="true" />
77
+ New article
78
+ </Link>
79
+ </>
80
+ )}
81
+ pageTitle="Articles"
82
+ >
83
+ <div className="space-y-4" data-newsroom-articles>
84
+ <Tabs defaultValue="all" onValueChange={(value) => setFilter(value as ArticleFilter)} value={filter}>
85
+ <TabsList>
86
+ <TabsTrigger className="min-w-[5.5rem]" value="all">All</TabsTrigger>
87
+ <TabsTrigger className="min-w-[5.5rem]" value="drafts">Drafts</TabsTrigger>
88
+ <TabsTrigger className="min-w-[5.5rem]" value="published">Published</TabsTrigger>
89
+ </TabsList>
90
+ </Tabs>
91
+ {loadError ? <NewsroomOpsStatusBanner tone="error">{loadError}</NewsroomOpsStatusBanner> : null}
92
+ {rows === null && !loadError ? (
93
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
94
+ <Spinner /> Loading articles…
95
+ </div>
96
+ ) : null}
97
+ {rows !== null && visibleRows.length === 0 ? (
98
+ <p className="text-sm text-muted-foreground">Nothing here yet.</p>
99
+ ) : null}
100
+ <ul className="divide-y divide-border rounded-xl border border-border bg-card" data-newsroom-articles-list>
101
+ {visibleRows.map((row) => (
102
+ <li key={row.id}>
103
+ <Link
104
+ className="flex flex-col gap-1 px-4 py-3 hover:bg-muted/40 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
105
+ data-newsroom-article-row={row.slug}
106
+ href={getNewsroomNavHref(newsroomHref("articles", row.id), demo)}
107
+ >
108
+ <span className="min-w-0">
109
+ <span className="block truncate text-sm font-medium">{row.title}</span>
110
+ <span className="block truncate text-xs text-muted-foreground">
111
+ {row.slug}
112
+ {row.section ? ` · ${row.section}` : ""}
113
+ {row.type !== "article" ? ` · ${row.type}` : ""}
114
+ </span>
115
+ </span>
116
+ <Badge
117
+ data-newsroom-article-status
118
+ variant={row.status === "draft" ? "muted" : row.pending ? "outline" : "secondary"}
119
+ >
120
+ {rowStatusLabel(row)}
121
+ </Badge>
122
+ </Link>
123
+ </li>
124
+ ))}
125
+ </ul>
126
+ </div>
127
+ </NewsroomOpsShell>
128
+ );
129
+ }
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  BookOpenIcon,
5
5
  ClipboardListIcon,
6
+ FileTextIcon,
6
7
  LayoutGridIcon,
7
8
  MenuIcon,
8
9
  SearchIcon,
@@ -55,6 +56,7 @@ type NewsroomOpsShellProps = {
55
56
 
56
57
  const MOBILE_TAB_ICONS: Record<string, typeof LayoutGridIcon> = {
57
58
  overview: LayoutGridIcon,
59
+ articles: FileTextIcon,
58
60
  assignments: ClipboardListIcon,
59
61
  references: BookOpenIcon,
60
62
  topics: TagsIcon,
@@ -198,7 +200,7 @@ export function NewsroomOpsShell({
198
200
  className="shrink-0 border-t border-border bg-card/95 pb-[max(env(safe-area-inset-bottom),0.35rem)] pt-1 md:hidden"
199
201
  data-newsroom-ops-bottom-nav
200
202
  >
201
- <ul className="grid grid-cols-5 gap-1 px-2">
203
+ <ul className="grid grid-cols-6 gap-1 px-1">
202
204
  {mobilePrimary.map((item) => {
203
205
  const Icon = MOBILE_TAB_ICONS[item.id] ?? LayoutGridIcon;
204
206
  const active = resolvedActiveTab === item.id;
@@ -81,7 +81,7 @@ import { cn } from "../lib/utils";
81
81
  import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
82
82
  import { ReferenceSourcePreview } from "./reference-source-preview";
83
83
  import type { ReaderAuthSnapshot } from "./reader-auth-state";
84
- import { ReaderAuthControl } from "./reader-auth-control";
84
+ import { NewsDeskAccessGate } from "./newsroom-access-gate";
85
85
  import { NewsroomOpsOverview } from "./newsroom-ops-overview";
86
86
  import { NewsroomReferencesView } from "./newsroom-references-view";
87
87
  import { NewsroomAssignmentsView } from "./newsroom-assignments-view";
@@ -169,7 +169,6 @@ import {
169
169
  uniqueNeighborGroupsForCategoryContext,
170
170
  type CategoryDrilldownContext,
171
171
  } from "../lib/newsroom-category-drilldown";
172
- import type { NewsDeskShellState } from "../lib/news-desk-session";
173
172
  import {
174
173
  resolveNewsroomCardTemplate,
175
174
  type NewsroomCardSpan,
@@ -15399,63 +15398,6 @@ function readTextClaim(value: unknown): string | null {
15399
15398
  return typeof value === "string" && value.trim() ? value.trim() : null;
15400
15399
  }
15401
15400
 
15402
- function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell: NewsDeskShellState | null; showSectionTabs?: boolean }) {
15403
- const accessPhase = shell?.phase ?? "checkingAccess";
15404
-
15405
- return (
15406
- <NewsroomOpsShell
15407
- activeTab="overview"
15408
- appTitle={SITE_BRAND.appTitle}
15409
- backHref="/"
15410
- backLabel={SITE_BRAND.backToHomeLabel}
15411
- headerActions={(
15412
- <Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
15413
- )}
15414
- pageTitle="Newsroom"
15415
- showNavigation={showSectionTabs}
15416
- >
15417
- <section aria-live="polite" data-news-desk-access={accessPhase} data-news-desk-access-phase={accessPhase}>
15418
- <div className="space-y-3 rounded-xl border border-border bg-card p-6">
15419
- <p className="m-0 text-[0.7rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Access</p>
15420
- <h2 className="m-0 font-sans text-xl font-semibold tracking-tight">{formatAccessTitle(shell)}</h2>
15421
- <p className="m-0 text-sm text-muted-foreground">{formatAccessDetail(shell)}</p>
15422
- {shell?.error ? <p className="text-sm text-destructive">{shell.error}</p> : null}
15423
- <p className="m-0 text-sm text-foreground">{formatAccessActionDetail(shell)}</p>
15424
- {shell?.phase === "signedOut" || shell?.phase === "error" ? (
15425
- <div className="news-desk-access-panel__auth">
15426
- <ReaderAuthControl postAuthPath="/newsroom" showIdentity />
15427
- </div>
15428
- ) : null}
15429
- </div>
15430
- </section>
15431
- </NewsroomOpsShell>
15432
- );
15433
- }
15434
-
15435
- function formatAccessTitle(state: NewsDeskShellState | null): string {
15436
- if (!state || state.phase === "checkingAccess") return "Checking Desk Credentials";
15437
- if (state.phase === "loadingDesk") return "Loading Newsroom Records";
15438
- if (state.phase === "forbidden") return "Editor Role Required";
15439
- if (state.phase === "error") return "Newsroom Unavailable";
15440
- return "Editor Sign-In Required";
15441
- }
15442
-
15443
- function formatAccessDetail(state: NewsDeskShellState | null): string {
15444
- if (!state || state.phase === "checkingAccess") return `${SITE_BRAND.appTitle} is checking the current browser session before loading steering state.`;
15445
- if (state.phase === "loadingDesk") return `${SITE_BRAND.appTitle} verified the browser session and is loading private Newsroom records.`;
15446
- if (state.phase === "forbidden") return "This account is signed in, but the Cognito session does not include the editor or admin group.";
15447
- if (state.phase === "error") return `${SITE_BRAND.appTitle} could not verify this editor session or load the private Newsroom data.`;
15448
- return "Sign in with an editor or admin account to inspect category, category tree, ontology, and graph steering.";
15449
- }
15450
-
15451
- function formatAccessActionDetail(state: NewsDeskShellState | null): string {
15452
- if (!state || state.phase === "checkingAccess") return "This should only take a moment. If it hangs, reload the page.";
15453
- if (state.phase === "loadingDesk") return "Private records are loading from GraphQL. Large reference queues may take several seconds.";
15454
- if (state.phase === "forbidden") return "Ask an admin to add this account to the editor or admin group, then sign out and back in.";
15455
- if (state.phase === "error") return "Reload the page or check the local development console for the GraphQL error.";
15456
- return "Use the login button above to authenticate with an editor or admin account.";
15457
- }
15458
-
15459
15401
  function SectionHeader({ title, detail }: { title: string; detail: string }) {
15460
15402
  const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
15461
15403
  return (
@@ -1,6 +1,7 @@
1
- import { ArnFormat, CfnOutput, Stack } from "aws-cdk-lib";
1
+ import { ArnFormat, CfnOutput, Duration, Stack } from "aws-cdk-lib";
2
2
  import * as iam from "aws-cdk-lib/aws-iam";
3
3
  import * as amplify from "aws-cdk-lib/aws-amplify";
4
+ import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
4
5
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
5
6
  import { resolveStagingDomainName } from "./site-config.js";
6
7
  function environmentVariables(variables) {
@@ -100,6 +101,60 @@ export class AmplifyAppShellStack extends Stack {
100
101
  if (config.reader) {
101
102
  this.readerAppId = this.addReaderApp(config, config.reader, cmsApp.attrAppId);
102
103
  }
104
+ if (config.github) {
105
+ this.addGithubCiRole(config, config.github, [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])]);
106
+ }
107
+ }
108
+ addGithubCiRole(config, github, amplifyAppIds) {
109
+ const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubOidcProvider", this.formatArn({
110
+ service: "iam",
111
+ region: "",
112
+ resource: "oidc-provider",
113
+ resourceName: GITHUB_OIDC_PROVIDER_HOST,
114
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
115
+ }));
116
+ const ciRole = new iam.Role(this, "GithubCiRole", {
117
+ roleName: `${config.siteId}-github-ci`,
118
+ description: `GitHub Actions role for ${github.owner}/${github.repo} (OIDC, least privilege, no admin policy)`,
119
+ maxSessionDuration: Duration.hours(1),
120
+ assumedBy: new iam.OpenIdConnectPrincipal(provider, {
121
+ StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
122
+ StringLike: {
123
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.map((branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`),
124
+ },
125
+ }),
126
+ });
127
+ ciRole.addToPolicy(new iam.PolicyStatement({
128
+ sid: "StartAndInspectAmplifyJobs",
129
+ actions: ["amplify:StartJob", "amplify:ListJobs", "amplify:GetJob", "amplify:ListBranches"],
130
+ resources: amplifyAppIds.flatMap((appId) => [
131
+ this.formatArn({ service: "amplify", resource: "apps", resourceName: appId, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
132
+ this.formatArn({ service: "amplify", resource: "apps", resourceName: `${appId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
133
+ ]),
134
+ }));
135
+ ciRole.addToPolicy(new iam.PolicyStatement({
136
+ sid: "ReadSiteSecretsForJwtMinting",
137
+ actions: ["ssm:GetParameter", "ssm:GetParameters"],
138
+ resources: [
139
+ this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/${this.cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
140
+ this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/shared/${this.cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
141
+ ],
142
+ }));
143
+ ciRole.addToPolicy(new iam.PolicyStatement({
144
+ sid: "WhoAmI",
145
+ actions: ["sts:GetCallerIdentity"],
146
+ resources: ["*"],
147
+ }));
148
+ if (github.ciCanDeployInfra) {
149
+ ciRole.addToPolicy(new iam.PolicyStatement({
150
+ sid: "DeployThisSitesAppShellStack",
151
+ actions: ["cloudformation:*"],
152
+ resources: [
153
+ this.formatArn({ service: "cloudformation", resource: "stack", resourceName: `amplify-app-shell-${config.siteId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
154
+ ],
155
+ }));
156
+ }
157
+ new CfnOutput(this, "GithubCiRoleArn", { value: ciRole.roleArn, description: `Role for GitHub Actions in ${github.owner}/${github.repo}` });
103
158
  }
104
159
  addReaderApp(config, reader, cmsAppId) {
105
160
  const readerAppName = reader.appName ?? `${config.siteId}-reader`;
@@ -1,7 +1,8 @@
1
1
  import { Construct } from "constructs";
2
- import { ArnFormat, CfnOutput, Stack, StackProps } from "aws-cdk-lib";
2
+ import { ArnFormat, CfnOutput, Duration, Stack, StackProps } from "aws-cdk-lib";
3
3
  import * as iam from "aws-cdk-lib/aws-iam";
4
4
  import * as amplify from "aws-cdk-lib/aws-amplify";
5
+ import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
5
6
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
6
7
  import { AmplifyAppShellSiteConfig, resolveStagingDomainName } from "./site-config";
7
8
 
@@ -115,6 +116,83 @@ export class AmplifyAppShellStack extends Stack {
115
116
  if (config.reader) {
116
117
  this.readerAppId = this.addReaderApp(config, config.reader, cmsApp.attrAppId);
117
118
  }
119
+
120
+ if (config.github) {
121
+ this.addGithubCiRole(config, config.github, [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])]);
122
+ }
123
+ }
124
+
125
+ private addGithubCiRole(
126
+ config: AmplifyAppShellSiteConfig,
127
+ github: NonNullable<AmplifyAppShellSiteConfig["github"]>,
128
+ amplifyAppIds: string[],
129
+ ): void {
130
+ const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
131
+ this,
132
+ "GithubOidcProvider",
133
+ this.formatArn({
134
+ service: "iam",
135
+ region: "",
136
+ resource: "oidc-provider",
137
+ resourceName: GITHUB_OIDC_PROVIDER_HOST,
138
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
139
+ }),
140
+ );
141
+
142
+ const ciRole = new iam.Role(this, "GithubCiRole", {
143
+ roleName: `${config.siteId}-github-ci`,
144
+ description: `GitHub Actions role for ${github.owner}/${github.repo} (OIDC, least privilege, no admin policy)`,
145
+ maxSessionDuration: Duration.hours(1),
146
+ assumedBy: new iam.OpenIdConnectPrincipal(provider, {
147
+ StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
148
+ StringLike: {
149
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.map(
150
+ (branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`,
151
+ ),
152
+ },
153
+ }),
154
+ });
155
+
156
+ ciRole.addToPolicy(
157
+ new iam.PolicyStatement({
158
+ sid: "StartAndInspectAmplifyJobs",
159
+ actions: ["amplify:StartJob", "amplify:ListJobs", "amplify:GetJob", "amplify:ListBranches"],
160
+ resources: amplifyAppIds.flatMap((appId) => [
161
+ this.formatArn({ service: "amplify", resource: "apps", resourceName: appId, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
162
+ this.formatArn({ service: "amplify", resource: "apps", resourceName: `${appId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
163
+ ]),
164
+ }),
165
+ );
166
+ ciRole.addToPolicy(
167
+ new iam.PolicyStatement({
168
+ sid: "ReadSiteSecretsForJwtMinting",
169
+ actions: ["ssm:GetParameter", "ssm:GetParameters"],
170
+ resources: [
171
+ this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/${this.cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
172
+ this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/shared/${this.cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
173
+ ],
174
+ }),
175
+ );
176
+ ciRole.addToPolicy(
177
+ new iam.PolicyStatement({
178
+ sid: "WhoAmI",
179
+ actions: ["sts:GetCallerIdentity"],
180
+ resources: ["*"],
181
+ }),
182
+ );
183
+ if (github.ciCanDeployInfra) {
184
+ ciRole.addToPolicy(
185
+ new iam.PolicyStatement({
186
+ sid: "DeployThisSitesAppShellStack",
187
+ actions: ["cloudformation:*"],
188
+ resources: [
189
+ this.formatArn({ service: "cloudformation", resource: "stack", resourceName: `amplify-app-shell-${config.siteId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
190
+ ],
191
+ }),
192
+ );
193
+ }
194
+
195
+ new CfnOutput(this, "GithubCiRoleArn", { value: ciRole.roleArn, description: `Role for GitHub Actions in ${github.owner}/${github.repo}` });
118
196
  }
119
197
 
120
198
  private addReaderApp(
@@ -0,0 +1,24 @@
1
+ import { CfnOutput, Stack } from "aws-cdk-lib";
2
+ import * as iam from "aws-cdk-lib/aws-iam";
3
+ export const GITHUB_OIDC_PROVIDER_HOST = "token.actions.githubusercontent.com";
4
+ /**
5
+ * Account-global singleton: AWS allows one IAM OIDC provider per issuer URL per
6
+ * account, so this never belongs in a per-site stack. Deploy it once per
7
+ * account, and only after `aws iam list-open-id-connect-providers` shows no
8
+ * provider for token.actions.githubusercontent.com. When one already exists,
9
+ * do not deploy this stack: per-site CI roles only reference the provider by
10
+ * its ARN (arn:aws:iam::<account>:oidc-provider/token.actions.githubusercontent.com)
11
+ * and work against an existing provider unchanged.
12
+ */
13
+ export class GithubOidcProviderStack extends Stack {
14
+ providerArn;
15
+ constructor(scope, id, props) {
16
+ super(scope, id, props);
17
+ const provider = new iam.OpenIdConnectProvider(this, "GithubOidc", {
18
+ url: `https://${GITHUB_OIDC_PROVIDER_HOST}`,
19
+ clientIds: ["sts.amazonaws.com"],
20
+ });
21
+ this.providerArn = provider.openIdConnectProviderArn;
22
+ new CfnOutput(this, "GithubOidcProviderArn", { value: provider.openIdConnectProviderArn });
23
+ }
24
+ }
@@ -0,0 +1,30 @@
1
+ import { Construct } from "constructs";
2
+ import { CfnOutput, Stack, StackProps } from "aws-cdk-lib";
3
+ import * as iam from "aws-cdk-lib/aws-iam";
4
+
5
+ export const GITHUB_OIDC_PROVIDER_HOST = "token.actions.githubusercontent.com";
6
+
7
+ /**
8
+ * Account-global singleton: AWS allows one IAM OIDC provider per issuer URL per
9
+ * account, so this never belongs in a per-site stack. Deploy it once per
10
+ * account, and only after `aws iam list-open-id-connect-providers` shows no
11
+ * provider for token.actions.githubusercontent.com. When one already exists,
12
+ * do not deploy this stack: per-site CI roles only reference the provider by
13
+ * its ARN (arn:aws:iam::<account>:oidc-provider/token.actions.githubusercontent.com)
14
+ * and work against an existing provider unchanged.
15
+ */
16
+ export class GithubOidcProviderStack extends Stack {
17
+ public readonly providerArn: string;
18
+
19
+ constructor(scope: Construct, id: string, props?: StackProps) {
20
+ super(scope, id, props);
21
+
22
+ const provider = new iam.OpenIdConnectProvider(this, "GithubOidc", {
23
+ url: `https://${GITHUB_OIDC_PROVIDER_HOST}`,
24
+ clientIds: ["sts.amazonaws.com"],
25
+ });
26
+
27
+ this.providerArn = provider.openIdConnectProviderArn;
28
+ new CfnOutput(this, "GithubOidcProviderArn", { value: provider.openIdConnectProviderArn });
29
+ }
30
+ }
package/infra/index.js CHANGED
@@ -10,5 +10,6 @@ try {
10
10
  );
11
11
  }
12
12
  export const { AmplifyAppShellStack } = await import("./amplify-app-shell.js");
13
+ export const { GithubOidcProviderStack } = await import("./github-oidc-provider.js");
13
14
  export { parseSiteConfig } from "./site-config.js";
14
15
  export { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec, buildSpecFor } from "./build-specs.js";
@@ -1,3 +1,4 @@
1
+ export const DEFAULT_CI_BRANCHES = ["main", "staging"];
1
2
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
2
3
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
3
4
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
@@ -5,6 +6,8 @@ const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
5
6
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
6
7
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
7
8
  const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
9
+ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
10
+ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
8
11
  const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
9
12
  function fail(field, problem) {
10
13
  throw new Error(`site.json: ${field} ${problem}`);
@@ -54,7 +57,7 @@ export function resolveStoragePreviewPrefix(config) {
54
57
  }
55
58
  export function parseSiteConfig(raw) {
56
59
  const record = requireObject(raw, "site");
57
- rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix"], "site");
60
+ rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
58
61
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
59
62
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
60
63
  const brand = requireString(record.brand, "brand");
@@ -96,6 +99,32 @@ export function parseSiteConfig(raw) {
96
99
  else if (frontend === "markus-static") {
97
100
  fail("reader", `is required when frontend is "markus-static"`);
98
101
  }
102
+ let github;
103
+ if (record.github !== undefined) {
104
+ const githubRecord = requireObject(record.github, "github");
105
+ rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
106
+ const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
107
+ const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
108
+ if (repository !== `https://github.com/${owner}/${repo}`) {
109
+ fail("github", `owner/repo must match repository ${repository}`);
110
+ }
111
+ let branches = DEFAULT_CI_BRANCHES;
112
+ if (githubRecord.branches !== undefined) {
113
+ if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
114
+ fail("github.branches", "must be a non-empty array of branch names");
115
+ }
116
+ branches = githubRecord.branches.map((branch, index) => {
117
+ if (typeof branch === "string" && /[*?]/.test(branch)) {
118
+ fail(`github.branches[${index}]`, `must not contain wildcards: ${JSON.stringify(branch)}`);
119
+ }
120
+ return requireString(branch, `github.branches[${index}]`, BRANCH_NAME_PATTERN);
121
+ });
122
+ }
123
+ const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
124
+ if (typeof ciCanDeployInfra !== "boolean")
125
+ fail("github.ciCanDeployInfra", "must be a boolean");
126
+ github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra };
127
+ }
99
128
  const config = {
100
129
  siteId,
101
130
  repository,
@@ -106,6 +135,7 @@ export function parseSiteConfig(raw) {
106
135
  reader,
107
136
  papyrusVersion,
108
137
  storagePreviewPrefix,
138
+ github,
109
139
  };
110
140
  const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
111
141
  if (redirectValue === undefined)
@@ -21,8 +21,16 @@ export type AmplifyAppShellSiteConfig = {
21
21
  };
22
22
  papyrusVersion: string;
23
23
  storagePreviewPrefix?: string;
24
+ github?: {
25
+ owner: string;
26
+ repo: string;
27
+ branches: string[];
28
+ ciCanDeployInfra: boolean;
29
+ };
24
30
  };
25
31
 
32
+ export const DEFAULT_CI_BRANCHES = ["main", "staging"];
33
+
26
34
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
27
35
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
28
36
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
@@ -30,6 +38,8 @@ const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
30
38
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
31
39
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
32
40
  const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
41
+ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
42
+ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
33
43
  const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
34
44
 
35
45
  function fail(field: string, problem: string): never {
@@ -87,7 +97,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
87
97
  const record = requireObject(raw, "site");
88
98
  rejectUnknownKeys(
89
99
  record,
90
- ["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix"],
100
+ ["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
91
101
  "site",
92
102
  );
93
103
 
@@ -133,6 +143,32 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
133
143
  fail("reader", `is required when frontend is "markus-static"`);
134
144
  }
135
145
 
146
+ let github: AmplifyAppShellSiteConfig["github"];
147
+ if (record.github !== undefined) {
148
+ const githubRecord = requireObject(record.github, "github");
149
+ rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
150
+ const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
151
+ const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
152
+ if (repository !== `https://github.com/${owner}/${repo}`) {
153
+ fail("github", `owner/repo must match repository ${repository}`);
154
+ }
155
+ let branches = DEFAULT_CI_BRANCHES;
156
+ if (githubRecord.branches !== undefined) {
157
+ if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
158
+ fail("github.branches", "must be a non-empty array of branch names");
159
+ }
160
+ branches = (githubRecord.branches as unknown[]).map((branch, index) => {
161
+ if (typeof branch === "string" && /[*?]/.test(branch)) {
162
+ fail(`github.branches[${index}]`, `must not contain wildcards: ${JSON.stringify(branch)}`);
163
+ }
164
+ return requireString(branch, `github.branches[${index}]`, BRANCH_NAME_PATTERN);
165
+ });
166
+ }
167
+ const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
168
+ if (typeof ciCanDeployInfra !== "boolean") fail("github.ciCanDeployInfra", "must be a boolean");
169
+ github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean };
170
+ }
171
+
136
172
  const config: AmplifyAppShellSiteConfig = {
137
173
  siteId,
138
174
  repository,
@@ -143,6 +179,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
143
179
  reader,
144
180
  papyrusVersion,
145
181
  storagePreviewPrefix,
182
+ github,
146
183
  };
147
184
 
148
185
  const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;