@anthusai/papyrus 1.0.0-next.4 → 1.0.0-next.5
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/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/bin/papyrus-app.mjs +12 -2
- package/bin/papyrus-infra.mjs +26 -0
- package/components/article-video.tsx +112 -3
- 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/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 +7 -1
- package/routes.manifest.json +11 -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
|
@@ -11,6 +11,9 @@ export const storage = defineStorage({
|
|
|
11
11
|
"corpora/*": [
|
|
12
12
|
allow.groups(["editor", "admin"]).to(["read"]),
|
|
13
13
|
],
|
|
14
|
+
"preview/*": [
|
|
15
|
+
allow.groups(["editor", "admin"]).to(["read"]),
|
|
16
|
+
],
|
|
14
17
|
"newsroom/*": [
|
|
15
18
|
allow.groups(["editor", "admin"]).to(["read", "write", "delete"]),
|
|
16
19
|
],
|
|
@@ -12,6 +12,9 @@ export const storage = defineStorage({
|
|
|
12
12
|
"corpora/*": [
|
|
13
13
|
allow.groups(["editor", "admin"]).to(["read"]),
|
|
14
14
|
],
|
|
15
|
+
"preview/*": [
|
|
16
|
+
allow.groups(["editor", "admin"]).to(["read"]),
|
|
17
|
+
],
|
|
15
18
|
"newsroom/*": [
|
|
16
19
|
allow.groups(["editor", "admin"]).to(["read", "write", "delete"]),
|
|
17
20
|
],
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { streamPreviewObject } from "../../../lib/staging-preview-object";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
|
|
6
|
+
export async function GET(request: NextRequest): Promise<Response> {
|
|
7
|
+
return streamPreviewObject(request.nextUrl.pathname);
|
|
8
|
+
}
|
package/app/globals.css
CHANGED
|
@@ -2282,6 +2282,25 @@ img {
|
|
|
2282
2282
|
width: 100%;
|
|
2283
2283
|
}
|
|
2284
2284
|
|
|
2285
|
+
.article-video__preview-frame {
|
|
2286
|
+
aspect-ratio: 16 / 9;
|
|
2287
|
+
background: #000;
|
|
2288
|
+
border: 0;
|
|
2289
|
+
display: block;
|
|
2290
|
+
width: 100%;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
.article-video__preview-controls {
|
|
2294
|
+
align-items: center;
|
|
2295
|
+
display: flex;
|
|
2296
|
+
gap: 0.75rem;
|
|
2297
|
+
margin-top: 0.5rem;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
.article-video__preview-controls input[type="range"] {
|
|
2301
|
+
flex: 1;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2285
2304
|
.article-video__credit {
|
|
2286
2305
|
color: rgb(var(--ink-solid-rgb) / 0.62);
|
|
2287
2306
|
font-size: 0.82rem;
|
package/bin/papyrus-app.mjs
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { sync } from "../src/sync.mjs";
|
|
3
3
|
|
|
4
|
-
const [command] = process.argv.slice(2);
|
|
4
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
5
5
|
if (command === "sync") {
|
|
6
6
|
sync();
|
|
7
|
+
} else if (command === "videoml-bundle") {
|
|
8
|
+
const preview = rest.includes("--preview");
|
|
9
|
+
const { buildBrowserBundle } = await import("../scripts/videoml/build-browser-bundle.mjs");
|
|
10
|
+
const { buildPreviewBundle } = await import("../scripts/videoml/build-preview-bundle.mjs");
|
|
11
|
+
try {
|
|
12
|
+
await (preview ? buildPreviewBundle() : buildBrowserBundle());
|
|
13
|
+
} catch (error) {
|
|
14
|
+
console.error(error.message);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
7
17
|
} else {
|
|
8
|
-
console.error("Usage: papyrus-app sync");
|
|
18
|
+
console.error("Usage: papyrus-app sync | papyrus-app videoml-bundle [--preview]");
|
|
9
19
|
process.exit(command ? 1 : 0);
|
|
10
20
|
}
|
package/bin/papyrus-infra.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]
|
|
4
|
+
* papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]
|
|
4
5
|
* papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader
|
|
5
6
|
* Synthesizes the app-shell stack for a publication from its own site.json, or
|
|
6
7
|
* prints one of the generated Amplify build specs. Synth only: deploying is a
|
|
@@ -14,6 +15,7 @@ import { parseSiteConfig } from "../infra/site-config.js";
|
|
|
14
15
|
|
|
15
16
|
const USAGE = [
|
|
16
17
|
"Usage: papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]",
|
|
18
|
+
" papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]",
|
|
17
19
|
" papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
|
|
18
20
|
].join("\n");
|
|
19
21
|
|
|
@@ -28,6 +30,30 @@ if (command !== "synth" && command !== "print-buildspec") {
|
|
|
28
30
|
process.exit(command ? 1 : 0);
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
const accountStack = opt("account-stack");
|
|
34
|
+
if (accountStack !== undefined) {
|
|
35
|
+
if (command !== "synth" || accountStack !== "github-oidc") {
|
|
36
|
+
console.error(USAGE);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
let GithubOidcProviderStack;
|
|
40
|
+
try {
|
|
41
|
+
({ GithubOidcProviderStack } = await import("../infra/index.js"));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error(`papyrus-infra: ${error instanceof Error ? error.message : error}`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
const { App } = createRequire(path.join(process.cwd(), "noop.js"))("aws-cdk-lib");
|
|
47
|
+
const app = new App({ outdir: path.resolve(opt("out", "cdk.out")) });
|
|
48
|
+
new GithubOidcProviderStack(app, "GithubOidcProviderStack", {
|
|
49
|
+
env: { account: opt("account", "335163751677"), region: opt("region", "us-east-1") },
|
|
50
|
+
stackName: "github-oidc-provider",
|
|
51
|
+
});
|
|
52
|
+
app.synth();
|
|
53
|
+
console.log(`synthesized github-oidc-provider -> ${path.resolve(opt("out", "cdk.out"))}`);
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
|
|
31
57
|
let config;
|
|
32
58
|
try {
|
|
33
59
|
config = parseSiteConfig(JSON.parse(fs.readFileSync(path.resolve(opt("site", "infra/site.json")), "utf8")));
|
|
@@ -1,31 +1,140 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import type { ArticleVideoAsset } from "../lib/articles";
|
|
5
5
|
import { resolveThemedVideoSrc } from "../lib/themed-image";
|
|
6
6
|
import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
|
|
7
|
+
import { getSiteBrand } from "../lib/site-brand";
|
|
8
|
+
import { normalizeDevPreviewDsl, type VideoScriptRef } from "../lib/video-script";
|
|
9
|
+
import { resolveVideoMode, resolveVideoModeFromEnv, type VideoMode } from "../lib/video-mode";
|
|
7
10
|
|
|
8
11
|
type ArticleVideoFigureProps = {
|
|
9
12
|
video: ArticleVideoAsset;
|
|
10
13
|
slug: string;
|
|
11
14
|
figureClassName?: string;
|
|
12
15
|
priority?: boolean;
|
|
16
|
+
videoScript?: VideoScriptRef | null;
|
|
13
17
|
};
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
const PREVIEW_STORAGE_PREFIX = "papyrus-video-preview:";
|
|
20
|
+
|
|
21
|
+
export function ArticleVideoFigure({
|
|
22
|
+
video,
|
|
23
|
+
slug,
|
|
24
|
+
figureClassName = "article-photo article-video",
|
|
25
|
+
videoScript = null,
|
|
26
|
+
}: ArticleVideoFigureProps) {
|
|
16
27
|
const theme = useResolvedPapyrusTheme();
|
|
17
28
|
const [hasHydrated, setHasHydrated] = useState(false);
|
|
29
|
+
const [videoMode, setVideoMode] = useState<VideoMode>(() => resolveVideoModeFromEnv());
|
|
30
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
31
|
+
const [currentTime, setCurrentTime] = useState(0);
|
|
32
|
+
const [duration, setDuration] = useState(0);
|
|
33
|
+
const [previewReady, setPreviewReady] = useState(false);
|
|
34
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
18
35
|
|
|
19
36
|
useEffect(() => {
|
|
20
37
|
setHasHydrated(true);
|
|
38
|
+
setVideoMode(resolveVideoMode());
|
|
21
39
|
}, []);
|
|
22
40
|
|
|
23
41
|
// Match SSR to the default dark MP4 in `video.src` until after hydration.
|
|
24
42
|
const resolvedTheme = hasHydrated ? theme : "dark";
|
|
25
43
|
const src = resolveThemedVideoSrc(video.src, video.themeVariants, resolvedTheme);
|
|
44
|
+
const usePreview = videoMode === "preview" && Boolean(videoScript?.dsl);
|
|
45
|
+
const previewDsl = videoScript?.dsl
|
|
46
|
+
? normalizeDevPreviewDsl(videoScript.dsl, getSiteBrand().video?.sceneComponents)
|
|
47
|
+
: null;
|
|
48
|
+
const previewSrc = `/videoml/preview.html?target=${encodeURIComponent(slug)}&theme=${resolvedTheme}${
|
|
49
|
+
src ? `&audio=${encodeURIComponent(src)}` : ""
|
|
50
|
+
}`;
|
|
51
|
+
|
|
52
|
+
if (usePreview && previewDsl && typeof window !== "undefined") {
|
|
53
|
+
sessionStorage.setItem(`${PREVIEW_STORAGE_PREFIX}${slug}`, previewDsl);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
setPreviewReady(false);
|
|
58
|
+
setIsPlaying(false);
|
|
59
|
+
setCurrentTime(0);
|
|
60
|
+
setDuration(0);
|
|
61
|
+
}, [previewSrc, usePreview]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!usePreview || !previewReady) return;
|
|
65
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
66
|
+
{ kind: "papyrus-video-refresh", target: slug, theme: resolvedTheme, audio: src || undefined },
|
|
67
|
+
"*",
|
|
68
|
+
);
|
|
69
|
+
}, [previewReady, resolvedTheme, slug, src, usePreview]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!usePreview) return;
|
|
73
|
+
const onMessage = (event: MessageEvent) => {
|
|
74
|
+
const payload = event.data;
|
|
75
|
+
if (!payload || typeof payload !== "object" || payload.kind !== "papyrus-video-time") return;
|
|
76
|
+
if (typeof payload.currentTime === "number") setCurrentTime(payload.currentTime);
|
|
77
|
+
if (typeof payload.duration === "number") setDuration(payload.duration);
|
|
78
|
+
};
|
|
79
|
+
window.addEventListener("message", onMessage);
|
|
80
|
+
return () => window.removeEventListener("message", onMessage);
|
|
81
|
+
}, [usePreview]);
|
|
82
|
+
|
|
83
|
+
const togglePreviewPlayback = useCallback(() => {
|
|
84
|
+
iframeRef.current?.contentWindow?.postMessage({ kind: isPlaying ? "papyrus-video-pause" : "papyrus-video-play" }, "*");
|
|
85
|
+
setIsPlaying(!isPlaying);
|
|
86
|
+
}, [isPlaying]);
|
|
87
|
+
|
|
88
|
+
const seekPreview = useCallback((nextTime: number) => {
|
|
89
|
+
const clamped = Math.max(0, Number.isFinite(nextTime) ? nextTime : 0);
|
|
90
|
+
iframeRef.current?.contentWindow?.postMessage({ kind: "papyrus-video-seek", time: clamped }, "*");
|
|
91
|
+
setCurrentTime(clamped);
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
if (usePreview) {
|
|
95
|
+
return (
|
|
96
|
+
<figure
|
|
97
|
+
className={`${figureClassName} article-video--preview`}
|
|
98
|
+
data-media-type="videoml-preview"
|
|
99
|
+
data-video-theme={resolvedTheme}
|
|
100
|
+
data-video-mode={videoMode}
|
|
101
|
+
>
|
|
102
|
+
{hasHydrated ? (
|
|
103
|
+
<iframe
|
|
104
|
+
key={`${slug}-${resolvedTheme}-${src}`}
|
|
105
|
+
ref={iframeRef}
|
|
106
|
+
className="article-video__preview-frame"
|
|
107
|
+
src={previewSrc}
|
|
108
|
+
title={video.alt}
|
|
109
|
+
onLoad={() => setPreviewReady(true)}
|
|
110
|
+
/>
|
|
111
|
+
) : null}
|
|
112
|
+
<div className="article-video__preview-controls">
|
|
113
|
+
<button type="button" onClick={togglePreviewPlayback} aria-pressed={isPlaying}>
|
|
114
|
+
{isPlaying ? "Pause video" : "Play video"}
|
|
115
|
+
</button>
|
|
116
|
+
<input
|
|
117
|
+
type="range"
|
|
118
|
+
min={0}
|
|
119
|
+
max={duration || 0}
|
|
120
|
+
step={0.1}
|
|
121
|
+
value={Math.min(currentTime, duration || 0)}
|
|
122
|
+
onChange={(event) => seekPreview(Number(event.target.value))}
|
|
123
|
+
aria-label="Seek video"
|
|
124
|
+
disabled={!duration}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
{video.caption ? <figcaption>{video.caption}</figcaption> : null}
|
|
128
|
+
{video.credit ? <p className="article-video__credit">{video.credit}</p> : null}
|
|
129
|
+
<span className="sr-only" data-video-slug={slug}>
|
|
130
|
+
{video.alt}
|
|
131
|
+
</span>
|
|
132
|
+
</figure>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
26
135
|
|
|
27
136
|
return (
|
|
28
|
-
<figure className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme}>
|
|
137
|
+
<figure className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme} data-video-mode={videoMode}>
|
|
29
138
|
<video
|
|
30
139
|
controls
|
|
31
140
|
playsInline
|
|
@@ -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;
|
package/lib/site-brand.ts
CHANGED
|
@@ -34,6 +34,18 @@ export type SiteBrandDemoEdition = {
|
|
|
34
34
|
editionVideo?: ArticleVideoAsset;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Video slot: how a publication supplies its own VideoML scene components.
|
|
39
|
+
* Paths are relative to the publication root.
|
|
40
|
+
*/
|
|
41
|
+
export type SiteBrandVideo = {
|
|
42
|
+
/** TSX module that registers the brand's `@videoml` scene components via `window.Babulus.registerComponent`. */
|
|
43
|
+
bundleEntry: string;
|
|
44
|
+
/** DSL element aliases for the browser preview, e.g. `{ "quote-card": "acme-quote-card" }`. */
|
|
45
|
+
sceneComponents?: Record<string, string>;
|
|
46
|
+
rhythm?: Record<string, unknown>;
|
|
47
|
+
};
|
|
48
|
+
|
|
37
49
|
export type SiteBrand = {
|
|
38
50
|
id: SiteBrandId;
|
|
39
51
|
appTitle: string;
|
|
@@ -76,6 +88,7 @@ export type SiteBrand = {
|
|
|
76
88
|
publicationName: string;
|
|
77
89
|
components?: SiteBrandComponents;
|
|
78
90
|
demoEdition?: SiteBrandDemoEdition;
|
|
91
|
+
video?: SiteBrandVideo;
|
|
79
92
|
};
|
|
80
93
|
|
|
81
94
|
const SERIF_TEXT_FONT = 'Georgia, "Times New Roman", serif';
|