ymmooot

非エンジニアがLPをAIで自由に作れる安心安全な仕組み

AI を使った開発が進む中で、非エンジニアのメンバーでも簡単にランディングページ(LP)を作成できるようになった。もはや、使い捨ての LP の HTML や CSS を人間が書く必要は全くなく、LP を必要とするメンバーがエンジニアを介せずに作成し、公開までできるのが理想である。

理想の状態

https://example.com というサービスページがあり、サブドメインなどは使わずに、https://example.com/lp/some-campaign-page のような URL を目指すとする。

フロントエンドのリポジトリに直接 LP のソースコードを置いてもいいが、多くのデメリットがある。

  • LP のためだけにフロントエンド開発環境の構築が必要
  • リポジトリの書き込み権限を非エンジニアに明け渡すのはリスクが大きい
    • PR とレビューによる運用を避けられない
    • レビューの自動化もできるだろうが、本質的な解決ではない
  • 予約公開や他のメンバーによるプレビュー、承認機能などの不足
    • main に反映したら即 GHA でビルドして公開という直結した仕組みでは困難

ここではこのような運用を目指すことにする。

  1. LP を作りたいメンバーが、サービス本体から隔離された環境で自由に LP を作成
  2. サービス側の管理画面から、その LP に対する公開操作を行う
  3. サービス本体のリリースサイクルとは全く別に LP が独自に更新される

構築した仕組み

ここでは予約や承認などの複雑な機能は省略し、LP を公開・非公開にするだけのシンプルな仕組みを構築する。(予約公開や承認などの機能は、必要に応じて追加することができる)

まず、LP 専用のリポジトリ lp を作る。GitHub org に lp-editor というチームを作成し、lp への書き込み権限を付与する。制作に関わる非エンジニアメンバーはこのチームに所属させる。

全体像

lp repository: pages/<slug>/
        |
        | git push で GitHub Actions (aws s3 sync) を起動
        v
S3: preview/<slug>/  <---  CloudFront /lp-preview/*  (Basic 認証・noindex)
        |
        | 管理画面の公開操作により API が S3 上でコピー
        v
S3: public/<slug>/   <---  CloudFront /lp/*

                           CloudFront /*  --->  サービス本体

登場するのは以下の4つ。

  • lp リポジトリ: LP の静的ファイル置き場
  • インフラ: S3 バケットと CloudFront の behavior
  • API: 公開・非公開の操作
  • 管理画面: 公開操作を行う画面

lp リポジトリ

pages/<slug>/index.html を置くと、slug を URL パスにした LP が作れる。

pages/
  <slug>/
    index.html
    style.css
    img/...

ビルドツールは一切使わず、素の HTML / CSS / JS だけを置くルールにしている。
また、ブランチは main のみで、PR も作らず直接 push してよいこととしている。push すると GitHub Actions が pages/ を S3 の preview/ プレフィックスへ同期する。

main への push で反映されるのはプレビューのみで、本番には何も影響を与えない。これにより、レビュー無しの直 push を許可している。
このようなルールは CLAUDE.md に記載しておくことで、制作者が Claude Code で作業しようとすると自然とルールに従うようにしてある。

配信: CloudFront + S3

サービス本体は CloudFront を前段に置いて配信しているとする。そこに LP 用の behavior を2つ追加する。

パス オリジン その他
/lp/* S3 public/
/lp-preview/* S3 preview/ Basic 認証、X-Robots-Tag: noindex, nofollow

同じ S3 バケットを originPath だけ変えて2つのオリジンとして使う。

CloudFront Function でパスを書き換える

/lp/<slug>/ へのリクエストをそのまま S3 に投げると、キーが public/lp/<slug>/ になってしまう。また S3 オリジン(REST エンドポイント)はディレクトリの index.html を補完してくれない。そこで viewer-request の CloudFront Function で以下を行う。

  • /lp(/lp-preview)プレフィックスを取り除く
  • 末尾スラッシュなら index.html を補完する
  • プレビューは Authorization ヘッダーを見て Basic 認証する
  • /lp/<slug> を /lp/<slug>/ へリダイレクトする
    • 相対パスのアセット参照を正しく解決するために必要

なお、S3 はバケットを完全に非公開にし、OAC を持つ CloudFront だけに REST エンドポイント経由で読ませている。S3 のウェブサイトエンドポイントはバケットを公開状態にする必要があるため利用しない。

同じドメインで配信することの注意点

分離しているのはリポジトリとデプロイの経路であり、ブラウザから見ると LP はサービス本体と同じオリジンで動く。LP に含まれる JS はサービス本体の Cookie や localStorage に触れられ、ログイン中のユーザーとして API を呼ぶこともできてしまう。公開前に人間が確認するのは見た目であり、AI が書いた JS の中身まではレビューしない。

そこで /lp/* と /lp-preview/* の behavior に CloudFront の Response Headers Policy で CSP(Content Security Policy)を付け、LP の中でできることの上限をインフラ側で決めておく。

Content-Security-Policy: default-src 'self'; script-src 'none'; connect-src 'none'
  • default-src 'self': 画像や CSS などは同じドメインのものだけを許可する
  • script-src 'none': JS の実行を一切許可しない
  • connect-src 'none': fetch などによる通信を許可しない

LP の HTML に何が書かれていても、ブラウザがスクリプトの実行や通信を止めるので、サービス本体の Cookie や API には手が届かない。演出などで JS が必要な場合は script-src 'self' にして lp リポジトリに置いた JS だけを許可し、connect-src で通信先を絞るなど、LP の要件に合わせて緩める。

JS を許可する場合は、CSP に sandbox allow-scripts も付けておくとよい。allow-same-origin を付けなければ、LP は同じドメインでも別オリジンとして扱われ、サービス本体の Cookie や localStorage には触れられない。なお、フォーム送信や target="_blank" のリンクも禁止されるので、必要なら allow-forms や allow-popups を足す。

公開操作

公開・非公開はサービス本体の GraphQL API に生やす。いずれも公開権限を持つ管理者のみ実行可能とする。

type LandingPage {
  slug: String!
  publishedAt: DateTime
}

extend type Query {
  landingPages: [LandingPage!]! @admin(role: LP_PUBLISHER)
}

extend type Mutation {
  publishLandingPage(slug: String!): LandingPage! @admin(role: LP_PUBLISHER)
  unpublishLandingPage(slug: String!): LandingPage! @admin(role: LP_PUBLISHER)
}

公開・非公開の処理はどちらも S3 上で完結する。公開は preview/<slug>/ の中身を public/<slug>/ へコピーし、非公開は public/<slug>/ を削除する。CloudFront は /lp/* を public/ に向けているので、コピーした時点で /lp/<slug>/ から見えるようになり、削除すれば見えなくなる。

public/ はコピーした時点のスナップショットになるため、公開中の LP を GitHub 側で修正しても、変わるのは preview/ だけで本番には影響しない。修正はプレビュー URL で確認し、問題なければ管理画面から再度「公開」して本番に反映する。

管理画面上の LP の一覧は S3 の preview/ 直下のディレクトリを元に作り、DB の landing_pages テーブルには slug と published_at の公開状態だけを持たせる。行は初めて公開したときに作られ、非公開にすると published_at が NULL に戻る。これで LP を追加するたびに DB へ登録する必要がなくなり、push すればそのまま管理画面の一覧に出てくる。

他メンバーによる承認機能や予約公開機能は、APIを拡張することで追加できる。例えば、承認済みの LP だけを公開できるようにしたり、予約日時を指定してその時間になったら自動で公開するようにすることも可能。

まとめ

時系列順にまとめるとこうなる:

  1. LP 制作者が lp リポジトリに push する
  2. GitHub Actions がリポジトリの内容を S3 の preview/ に同期する
  3. この時点で CloudFront によって /lp-preview/* から Basic 認証付きでプレビューできる
  4. 管理画面から公開操作を行うと、API が S3 上で preview/ から public/ へコピーする
  5. CloudFront によって /lp/* から公開される

所感

誰でもコードを読み書きする手段を得た今日ではあるが、まだまだ非エンジニアが作成した物をノーレビューでプロダクトに載せるのは安全とは言えない。AI で作り、AI が直し、人間がレビューしない環境を、リポジトリとリリースの流れの上ではメインサービスから切り離し、管理画面を通じてメインサービスと結びつける運用は、今のところ順調にワークしており、有用な仕組みだと感じている。