先週、Antigravity 2.0 のエージェントが「トップから記事詳細まで全画面を実ブラウザで確認しました。異常はありません」と証跡付きで返してきました。安心してマージした翌朝、プレミアム記事のペイウォールが無料会員にも解除されて全文が見えていることに、読者の問い合わせで気づきました。
原因はコードではなく、確認の入口でした。エージェントが起動した Chrome は Cookie を持たない初回訪問者そのもので、無料の初期状態しか踏んでいません。課金導線という一番壊れてはいけない画面を、自己デバッグは一度も開いていなかったのです。個人開発で収益の柱がプレミアム記事とアプリ内課金である以上、この盲点は放置できません。私自身、App Store と Google Play で公開しているアプリの課金判定でも、検証端末がたまたま購入済み状態だったために「無料ユーザーの見え方」を長く見落としていた時期がありました。Web でもアプリでも、構図はまったく同じです。
既定の自己デバッグが「無料の初期状態」しか見ない理由
実ブラウザ自己デバッグは、ビルド中に本物の Chrome を起動して要素を操作し、スクリーンショットで自分の実装を検証します。速さは魅力ですが、その Chrome は毎回まっさらなプロファイルで立ち上がります。ログインセッションも、課金済みを示す Cookie も持ちません。
つまりエージェントが見ているのは、常にサイトの「顔」だけです。私のサイトでは記事本文の出し分けを次の3層で行っています。
状態 判定に使うもの エージェントが既定で踏むか
未ログイン・無料記事 Cookie なし 踏む(唯一ここだけ)
会員ログイン後 premium_token Cookie 踏まない
記事単体購入後 article_purchases Cookie 踏まない
3状態のうち2つを見ずに「全画面OK」と表明されても、それは全画面ではありません。エージェントの善意の合格報告が、かえって油断を生みます。証跡と承認の置き場そのものの設計は実ブラウザ自己デバッグの証跡と承認境界の設計 で扱いましたが、ここでは「そもそも正しい状態を見せられているか」という一段手前の問題を掘り下げます。
状態を「見せる」ための3つの入口
エージェントに課金後の画面を踏ませるには、状態を注入する入口を用意します。私は次の優先順で採用しています。
1. Cookie の事前注入(第一選択)
プレビュー環境限定で、ナビゲーション前に premium_token を焼き込みます。本番の Stripe フローを一切通さずに「会員としての見え方」を再現できるため、最も速くて安全です。
2. 課金後URLへのディープリンク
ペイウォール解除後の記事URLへ直接遷移させ、レンダリング結果を確認します。Cookie 注入と組み合わせて使います。
3. フラグによる強制解放(最後の手段)
環境変数でペイウォールを無効化する方法です。判定ロジック自体を迂回してしまうため、見た目の確認専用と割り切り、課金判定の検証には使いません。
認証済みセッションをエージェントのブラウザに注入する
プレビュー用の検証ハーネスを1本用意し、エージェントには「このハーネス経由で確認せよ」と指示します。以下は Playwright でプレビューに会員 Cookie を注入し、無料状態とプレミアム状態の両方を巡回する最小構成です。
// verify-paywall.ts — プレビュー環境専用の状態注入ハーネス
import { chromium, Browser, BrowserContext } from "playwright" ;
const PREVIEW = process.env. PREVIEW_URL ?? "https://preview.example.pages.dev" ;
const PREMIUM_ARTICLE = "/articles/agents/some-premium-slug" ;
// プレビュー限定の検証用トークン(本番の署名鍵とは別物を使う)
const PREVIEW_PREMIUM_TOKEN = process.env. PREVIEW_PREMIUM_TOKEN ?? "" ;
async function seedMemberContext ( browser : Browser ) : Promise < BrowserContext > {
const ctx = await browser. newContext ();
// ナビゲーション前に会員 Cookie を焼き込む
await ctx. addCookies ([
{
name: "premium_token" ,
value: PREVIEW_PREMIUM_TOKEN ,
domain: new URL ( PREVIEW ).hostname, // ドメイン不一致は無効化されるので厳密に
path: "/" ,
httpOnly: true ,
secure: true ,
sameSite: "Lax" ,
},
]);
return ctx;
}
async function main () {
const browser = await chromium. launch ();
// (A) 無料訪問者としてのペイウォール表示を確認
const guest = await browser. newContext ();
const guestPage = await guest. newPage ();
await guestPage. goto ( PREVIEW + PREMIUM_ARTICLE , { waitUntil: "networkidle" });
const paywallShown = await guestPage. locator ( "[data-paywall]" ). count ();
// (B) 会員としての全文表示を確認
const member = await seedMemberContext (browser);
const memberPage = await member. newPage ();
await memberPage. goto ( PREVIEW + PREMIUM_ARTICLE , { waitUntil: "networkidle" });
const fullBodyShown = await memberPage. locator ( "[data-premium-body]" ). count ();
console. log ( JSON . stringify ({ paywallShown, fullBodyShown }));
await browser. close ();
// 無料はペイウォールが出て、会員は本文が出る、が同時に成り立つべき
if (paywallShown < 1 || fullBodyShown < 1 ) {
process. exit ( 1 );
}
}
main ();
ポイントは、無料と会員を別々の BrowserContext として同一 run で必ず両方巡回することです。片方だけの確認では、今回のような「無料に全文が漏れる」回帰も「会員に本文が出ない」回帰も、どちらも取りこぼします。
ペイウォール後のDOMに到達しなければ run を失敗させる
ハーネスを用意しても、エージェントが「時間がないので無料側だけ見ました」と省略しては意味がありません。そこで、到達すべき状態を通過したかを表明(アサーション)として機械的に強制します。DOM にマーカーを置き、それを踏んだ記録がなければ run 全体を失敗させます。
// coverage-assert.ts — 必須状態の到達をカバレッジとして検査する
type Visited = { key : string ; seen : boolean };
const REQUIRED : Visited [] = [
{ key: "guest_paywall" , seen: false }, // 無料で data-paywall を見た
{ key: "member_full_body" , seen: false } // 会員で data-premium-body を見た
];
export function markVisited ( key : string ) {
const target = REQUIRED . find (( v ) => v.key === key);
if (target) target.seen = true ;
}
export function assertCoverage () : void {
const missed = REQUIRED . filter (( v ) => ! v.seen). map (( v ) => v.key);
if (missed. length > 0 ) {
// ここで落とすことで「無料画面だけ見て合格」を構造的に禁止する
throw new Error ( "uncovered paywall states: " + missed. join ( ", " ));
}
}
この2ファイルをプレビューの検証手順に組み込み、エージェントの手引きにも明記します。Antigravity のプロジェクト直下に置く手引きファイルへ、次のように状態カバレッジの契約を書いておくと、エージェントが勝手に無料側だけで済ませなくなります。
## 自己デバッグの必須カバレッジ
- 課金に関わる画面は verify-paywall.ts 経由でのみ検証すること
- guest_paywall と member_full_body の両方に到達したうえで assertCoverage() を通すこと
- どちらか一方でも未到達なら、その run は失敗として報告すること
個人開発でぶつかった落とし穴
再現とデバッグの過程で、素直に組むと壊れる箇所がいくつもありました。本番運用で実際につまずいた3点を挙げます。
まず、エッジキャッシュが無料版HTMLをエージェントに配ってしまう問題です。私のサイトはキャッシュワーカーが premium_token 保持者をバイパスする設計ですが、Cookie 注入前の初回リクエストがキャッシュに固定されると、会員として遷移しても無料版が返ります。対処は、検証時に DEPLOY_VERSION 相当のクエリでキャッシュを外すか、会員コンテキストの初回だけ no-store で取得することでした。エッジキャッシュと課金状態の衝突は根が深く、広告非表示・課金状態の Source of Truth 設計 で扱った合成判定の考え方がそのまま効きます。
次に、Cookie のドメイン不一致です。プレビューのサブドメインと Cookie の domain 属性が1文字でもずれると、ブラウザは黙って Cookie を捨てます。エラーは出ず、ただ無料版が表示されるだけなので、原因にたどり着くまで30分溶かしました。ホスト名は URL から機械的に取り出すのが確実です。私はこの手の「無言で状態が落ちる」不具合を最も警戒しており、注入直後に Cookie が実際に効いているかを1回アサートしてから本題の巡回に入るようにしています。
3つ目は bfcache です。戻る操作でペイウォール前の状態が復元され、会員遷移が無かったことにされる場面がありました。pageshow イベントで復元を検知して再取得する必要があります。この副作用の閉じ込め全般は自己デバッグを使い捨てプレビュー環境に向ける方法 と合わせて設計すると安全です。
どこまでエージェントに任せ、どこで人が見るか
私は、状態の巡回とマーカー到達の確認まではエージェントに任せ、実際のペイウォール前後のスクリーンショット2枚は必ず自分の目で見る運用を推奨します。表明はあくまで「その状態に到達したか」しか保証せず、「その状態が正しく見えているか」までは保証しないからです。マーカーには到達したのに、本文の一部だけが欠けている、という半端な壊れは目視でしか気づけません。
自動化率は上げつつ、収益に直結する2画面だけは人の承認を残す。この線引きにしてから、課金導線の回帰を本番に出す事故は止まりました。派手な仕組みではありませんが、無料側だけを見て安心する構造を断つことが、個人開発では一番効く投資だと感じています。
まず、手元のプレビューで無料コンテキストと会員コンテキストの2つを同一 run で開き、両方のスクリーンショットが並んで残るところから始めてみてください。片方しか残らないなら、それが今のあなたの自己デバッグの盲点です。お読みいただきありがとうございました。