Hugoで作ったブログに、有料記事を配信する仕組みを追加した。 AI AgentはMPPまたはx402で支払い、人間はStripe Checkoutで購入できる。
ただし、決済画面を追加するだけでは有料配信にならない。 Hugoが生成した公開ファイルに本文が残っていれば、URLや配信元を調べることで決済を迂回できるからである。
そこで、公開するプレビューと販売する完全版をビルド時に分離し、完全版だけをprivateなCloudflare R2へ保存した。 Cloudflare Workerは支払いを確認したリクエストに限り、R2からMarkdownまたはJSONを返す。
無料部分と有料部分の境界
この記事では、仕組みを理解して同じ設計を検討できるところまで無料で公開する。 具体的には、アーキテクチャ、課金範囲の指定方法、決済経路、本文を公開領域から除外する方法を説明する。
有料部分では、調査と組み立ての手間を省ける完成済み資料を提供する。 Cloudflareの設定例、Coinbase CDPのJWT認証コード、R2への配置手順、検証コマンド、導入チェックリスト、障害対応表をまとめた。
無料部分を読んだだけでも、設計を自分で実装できる。 有料部分は、同じ構成を短時間で再現したい人向けである。
Hugoだけでは本文を保護できない
HugoはMarkdownからHTMLを生成する静的サイトジェネレーターである。 生成後のHTML、RSS、検索用JSON、OGP、JSON-LDは、原則として公開ファイルになる。
そのため、有料部分をCSSやJavaScriptで非表示にする方法では保護できない。 画面に表示されていなくても、HTMLやAPIレスポンスに本文が含まれていれば取得できるためである。
今回の実装では、Hugoへ渡す本文自体を有料境界より前に制限した。
販売する完全版は別の生成処理で作り、Workers Assetsの配信元であるpublic/には置かない。
配信構成
全体は次の構成になった。
content/ja/posts/<slug>/index.md
│
├─ Hugo build
│ └─ 公開プレビュー → public/ → Workers Assets
│
└─ payments:generate
└─ 完全版 Markdown / JSON
│
▼
private R2
▲
│
Browser / AI Agent → Cloudflare Worker
│
┌────────────┴────────────┐
│ │
MPP / x402 Stripe Checkout
│ │
└──────── access control ─┘
記事の原稿は一つだけである。
同じindex.mdから公開プレビューと完全版を作るため、無料版と有料版を別々に更新する必要がない。
Front Matterで課金範囲を指定する
決済プロトコルは記事の属性ではないため、Front MatterにはMPPやStripeを直接指定していない。 記事側が持つのは、価格、通貨、公開方式、配信形式、アクセス時間である。
[payment]
enabled = true
mode = "partial"
amount = "0.50"
currency = "USD"
formats = ["markdown", "json"]
accessMinutes = 60
mode = "partial"は、記事の途中までを無料公開する指定である。
本文全体を販売する場合はmode = "full"を使う。
決済手段の選択はCloudflare Workerが担当する。 この分離により、将来facilitatorや人間向けの決済手段を変更しても、記事のFront Matterを一括修正せずに済む。
二つのマーカーで公開範囲を分ける
partialの記事には、Hugoの要約位置と有料部分の開始位置を別々に記述する。
一覧やOGPにも使う導入文です。
[要約マーカー]
記事ページでは、ここも無料で読めます。
[有料開始マーカー]
ここから有料部分です。
要約マーカーは、<!--の直後にmore-->を続けたHTMLコメントである。
一覧、RSS、検索用JSON、OGPへ出す要約の終端として使う。
有料開始マーカーは、<!--の直後にx402-->を続けたHTMLコメントである。
記事ページで無料表示する本文の終端として使う。
二つを分けた理由は、一覧の要約と記事ページのプレビューでは適切な長さが異なるからである。 生成処理はマーカーの順序、重複、無料部分と有料部分が空でないことを検査する。
完全版をprivate R2へ分離する
有料コンテンツの生成処理は、有料開始マーカーを境に原稿を分割する。 Hugoへは前半だけを渡し、private R2用には公開部分と有料部分を結合した完全版を出力する。
出力形式はMarkdownとJSONの二つである。 JSONには記事ID、言語、slug、タイトル、説明、課金方式、Markdown本文が入る。
.generated/payments/
├─ manifest.json
└─ articles/
└─ ja/
├─ <slug>.md
└─ <slug>.json
.generated/payments/はGit管理とWorkers Assetsの対象から外している。
Workerへ組み込むmanifestにも、価格とR2のキーだけを記録し、有料本文は含めない。
AI Agentの決済経路
AI Agentは次のAPIへアクセスする。
GET /api/articles/{lang}/{slug}.md
GET /api/articles/{lang}/{slug}.json
支払い情報がなければ、Workerは402 Payment Requiredを返す。
同じ応答に、MPPのWWW-Authenticate: Paymentとx402 v2のPAYMENT-REQUIREDを載せる。
二つの決済ミドルウェアを直列に置いてはいない。
mppxの一つのEVM charge methodがMPPとx402の両方を提示するため、利用者が二重に支払う構成にはならない。
支払いの検証とsettlementにはCoinbase CDP facilitatorを使う。 WorkerはCoinbase CDP SDKで有効期限120秒のJWTをリクエストごとに生成し、CDPの対象endpointだけに送る。
人間の決済経路
人間向けにはStripe Checkoutを使う。 記事ページの購入ボタンからWorkerへリクエストし、記事IDと形式をmetadataに持つCheckout Sessionを作成する。
支払い後は、Stripe Webhookと完了URLの両方で支払い状態を確認する。 確認できた場合だけ、購入した記事IDと形式に対応する署名付きCookieを発行する。
CookieにはHttpOnly、Secure、SameSite=Laxを設定した。
この記事ではaccessMinutes = 60としているため、アクセス権は発行から60分で失効する。
Stripe Checkoutには最低決済額がある。 現在の実装ではUSD 0.50未満をAgent API専用とし、Stripe Checkout Sessionの作成を拒否する。
有料本文を漏らさないための検査
テンプレートを修正しただけでは、RSSや構造化データに本文を出す経路が残りうる。 そこで、Hugoが生成する全ファイルを対象にした漏洩テストを追加した。
テスト用の有料部分には固有の文字列を置く。
ビルド後にpublic/全体を走査し、その文字列が公開ファイルに存在せず、private用Markdownには存在することを確認する。
bun run test:payments
公開ページだけを目視しても、RSS、検索用JSON、OGP、JSON-LDへの混入は見落としやすい。 有料配信では、決済処理と同じ程度に生成物の検査が必要となる。
ここから提供する完成版資料
ここまでの設計を使えば、同じ仕組みを一から実装できる。 ここから先には、現在の構成を再現するために使える次の資料を収録した。
- 記事と生成物のディレクトリ構成
partial記事用のFront Matterテンプレート- Cloudflare WorkerとR2の設定例
- Coinbase CDP JWT認証の実装例
- Stripe Checkoutとアクセス権の設定要点
- ローカル検証から本番反映までの手順
- 導入チェックリストと障害対応表