@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.
- package/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/content-actions/handler.py +44 -2
- package/amplify/rebuild-trigger-settings.js +22 -0
- package/amplify/rebuild-trigger-settings.ts +42 -0
- package/amplify/site-backend-config.ts +6 -0
- package/amplify/site-backend.js +18 -0
- package/amplify/site-backend.ts +20 -0
- package/amplify/storage/resource.js +3 -0
- package/amplify/storage/resource.ts +3 -0
- package/app/%5F_preview/[[...path]]/route.ts +8 -0
- package/app/globals.css +19 -0
- package/app/newsroom/articles/[id]/page.tsx +15 -0
- package/app/newsroom/articles/page.tsx +13 -0
- package/bin/papyrus-app.mjs +12 -2
- package/bin/papyrus-infra.mjs +26 -0
- package/components/article-video.tsx +112 -3
- package/components/markus-ir-preview.tsx +279 -0
- package/components/newsroom-access-gate.tsx +66 -0
- package/components/newsroom-article-editor.tsx +637 -0
- package/components/newsroom-articles-view.tsx +129 -0
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/topic-steering-workspace.tsx +1 -59
- package/infra/amplify-app-shell.js +56 -1
- package/infra/amplify-app-shell.ts +79 -1
- package/infra/github-oidc-provider.js +24 -0
- package/infra/github-oidc-provider.ts +30 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +31 -1
- package/infra/site-config.ts +38 -1
- package/lib/content-actions-demo.ts +172 -0
- package/lib/newsroom-articles.ts +325 -0
- package/lib/newsroom-demo-articles.ts +90 -0
- package/lib/newsroom-nav.ts +2 -0
- package/lib/site-brand.ts +13 -0
- package/lib/staging-preview-object.ts +98 -0
- package/lib/video-mode.ts +42 -0
- package/lib/video-script.ts +63 -0
- package/middleware.ts +14 -1
- package/package.json +16 -1
- package/routes.manifest.json +27 -1
- package/scripts/videoml/build-browser-bundle.mjs +22 -0
- package/scripts/videoml/build-preview-bundle.mjs +50 -0
- package/scripts/videoml/bundle-common.mjs +129 -0
- package/scripts/videoml/preview/preview-entry.tsx +381 -0
- package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
- package/scripts/videoml/preview/preview-timing.ts +42 -0
- package/scripts/videoml/preview.html +77 -0
- 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-
|
|
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 {
|
|
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";
|
package/infra/site-config.js
CHANGED
|
@@ -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)
|
package/infra/site-config.ts
CHANGED
|
@@ -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;
|