プライベート GitHub リポジトリのアクセス権を、SaaS を挟まずに販売する方法
RepoAccess のローンチ記事。支払いで GitHub チームへの招待を送り、返金やチャージバックでそれを取り消す、オープンソースの Cloudflare Worker について。
Polar の無料プランは、販売 1 件ごとに 5% プラス 50 セントを取ります。149 ドルのボイラープレートを 100 回売れば、メーターは 795 ドルを指します。
公平に言えば、その手数料はプラットフォームまるごと、チェックアウト、税金、紛争対応の対価です。しかし、すでに決済プロバイダーを持っているなら、足りないのは配信という一片だけです。Webhook 1 本と、購入者ごとの GitHub チーム招待 1 通。私はその隙間を作り、オープンソースにしました。
プライベート GitHub リポジトリのアクセス権を販売するのに、本当に必要なもの
仕事は簡単に聞こえますが、簡単ではありません。支払いがあったら、リポジトリを持つチームに購入者の GitHub アカウントを招待する。RepoAccess はこれを OAuth なしで行います。購入者は GitHub のユーザー名を入力し、メールの招待を受け入れるだけなので、ファネルの中で最も高くつく瞬間に承認すべきアプリはありません。
裏返せば、ユーザー名は打ち間違えられるので、購入者にはサポートのスレッドではない復旧の道が必要です。返金やチャージバックがあれば、アクセスを取り戻す。しかもあなたのスケジュールではなく、プロバイダーのスケジュールで。そしてこれらすべてを冪等にやる必要があります。決済プロバイダーは Webhook を再送するからです。
TL;DR
- RepoAccess は決済の Webhook を GitHub チームへの招待に変えます。返金とチャージバックは自動的にアクセスを取り消します
- 無料枠の Cloudflare Worker 1 つで動きます。サーバーなし、SaaS のサブスクリプションなし、販売ごとの手数料なし
- 購入者が OAuth アプリを承認することはありません。GitHub のユーザー名を入力し、メールの招待を受け入れるだけです
- AGPL のコアには完全な Stripe アダプターが含まれ、他のプロバイダーは文書化された契約に接続します
私自身の製品はダウンロードではなくプライベートリポジトリへのアクセスとして届けており、この配信の層こそ、誰かのプラットフォームを受け入れずには買えなかった部品でした。だから Cloudflare Worker 1 つとして作り、自分の製品をその後ろに置き、コアをオープンソースにしました。この記事はそのアーキテクチャをたどります。誰かが支払うと何が起きるのか、なぜサービスではなく Worker 1 つなのか、そして難しい部分が実際にはどこにあったのか。
下のコードを推測なしで読めるように、スタックを先に。TypeScript、ルーターとして Hono 4、ランタイムとして Cloudflare Workers、付与と取り消しの耐久性のあるステップに Cloudflare Workflows、クレームトークンと付与レコードに Workers KV。データベースなし、キューサービスなし、それ以上のフレームワークなし。コアは AGPL-3.0 で、npm に repoaccess-core として公開しています。
誰かが支払うと何が起きるか
どのプロバイダー連携も同じ玄関で終わります。プロバイダーはあなたの Worker の /wh/<adapter> に POST します。ルートはパースする前に検証します。Stripe の場合、バイト単位で一致する生のボディに対するタイミング安全な HMAC 検証です。検証の前に JSON を再シリアライズするのは、署名バグが生まれる典型的な経路だからです。そのあとで初めて、アダプターがプロバイダーのペイロードを、エンジンが話す唯一の形に翻訳します。
export interface NormalizedEvent {
event_type: 'payment_success' | 'refund' | 'chargeback'
product_id: string
/** Stable correlation key - identical across an order and its later refund/chargeback. */
transaction_id: string
buyer_email: string | null
github_username: string | null
/** Refund events only: true=full, false=partial, null=n/a. */
is_full_refund: boolean | null
}
3 種類のイベントが語彙のすべてです。プロバイダーが送るそれ以外のものはアダプターが取り除くノイズで、transaction_id が相関キーになります。注文の 3 週間後に届く返金も同じ ID を持ち、それによって取り消しは自分の付与を見つけます。
イベントはそのあと Cloudflare Workflow に入り、インスタンス ID は見た目以上の仕事をします。
const event = adapter.parse(raw)
if (!event) return c.text('unprocessable entity', 400)
// Deterministic Workflow id = the idempotency key. This IS the dedupe
// mechanism - no KV bookkeeping. A duplicate id is silently skipped.
const id = await workflowInstanceId(
adapter.name,
event.event_type,
event.transaction_id,
event.is_full_refund,
)
await c.env.ACCESS_WORKFLOW.createBatch([
{ id, params: { adapter: adapter.name, event, origin: 'webhook' } },
])
return c.text('ok', 200)
プロバイダーは Webhook を再送します。数秒後のこともあれば、Worker がとっくに 200 を返したあとのこともあります。よくある対策は、処理済みイベント ID の表です。
このエンジンにはそのような表がありません。インスタンス ID はイベント自体から導出されるので、再送は同じ ID を組み立て、Workflows のランタイムが重複をスキップします。帳簿による冪等性ではなく、構造による冪等性です。
そして is_full_refund が ID に入っているのには理由があります。2 段階で支払われた返金は 2 つのインスタンスにならなければならず、静かに 1 つに重複排除されてはいけません。詳しくは難しい部分の節で。
Workflow の中では、耐久性のある処理が再試行可能なステップとして走ります。product_id を GitHub チームに対応づけ、GitHub を呼び、ユーザー名を招待する。返金やチャージバックは同じパイプラインで逆の動詞を実行し、取り消しは保存されたフラグではなく GitHub の実際の状態との突き合わせです。すでに消えているメンバーシップは消えているものとして読まれ、DELETE は何もしません。この性質が、このエンジンのあらゆる再試行を無害にしています。
使えるユーザー名なしで支払いが届いたとき、つまり何も入力されていない、形式が不正、GitHub に存在しない名前のとき、付与はサポートのスレッドに落ちません。Worker はトランザクションに紐づいた使い捨てのクレームトークンを発行し、購入者は同じ Worker が配信するクレームページで手続きを終えます。ユーザー名を入力し、確認し、招待を受け取る。
プロジェクトではこれをタイポの経路と呼んでいて、どのプロバイダーのフローの一部でもない、安全網です。どのシステムにも捕まえられない唯一のケースは、他人の実在アカウントに一致してしまう打ち間違いです。下流のどこにも、それを正しい購入と区別する手段はありません。だからすべての access.granted イベントがトランザクションとハンドルの両方を持ち、「支払ったのに何も届かない」というチケットは一目で解決します。
なぜプラットフォームではなく Cloudflare Worker 1 つなのか
冒頭の計算に戻ります。プラットフォームのパーセンテージは、チェックアウト、税務処理、紛争の責任に対する公正な価格です。それらが必要なら。すでに Stripe や Paddle、あるいは Webhook を持つ何らかのプロバイダーを使っている販売者は、まさにそのために決済事業者へ支払っています。
残るのは配信で、配信は小さい。1 回の販売で Worker が使うのは Webhook リクエスト 1 回、KV 操作ひと握り、GitHub API 呼び出し 1 回です。
触れる中で最も厳しい無料枠の制限は KV の 1 日 1,000 回の書き込みで、1 回の販売は数回の書き込みです。つまり無料枠は 1 日数百件の販売をゼロ円で処理し、その間に遊んでいるサーバーはありません。
2 つ目の理由はアイデンティティです。フローのどこにも「Login with GitHub」はありません。登録する OAuth アプリも、コールバックのルートも、セッションストアも、静かに期限切れになる追加のシークレットもありません。Worker は購入者の身元を一切持ちません。決済イベントをチームへの招待に変える、それが関係のすべてです。
3 つ目の理由は信頼で、それはオープンソースから離れる方向ではなく、オープンソースへ向かう方向を指します。チームのメンバーシップを管理するために、Worker はあなたの Organization に対する実際の権限を持つ GitHub トークンを必要とします。そんなトークンを見知らぬ人のクローズドなバイナリに渡すつもりは私にはなく、あなたにもそれを期待しません。だからコアは AGPL-3.0 で、Webhook から GitHub 呼び出しまでのすべての行がリポジトリにあります。
難しい 20%: 署名、返金、冪等性
この製品のハッピーパスは週末プロジェクトで、そうでないふりはしません。信頼できるようになるまで数か月の実運用が必要だったのは、ハッピーパスの周りにあるすべてです。見知らぬ人が無料でアクセスを得るか、支払った購入者が自分のアクセスを失うかを決めるケースです。
パースした JSON ではなく、受け取ったバイトを検証する
HMAC 検証は、プロバイダーが送った正確なバイトに対して走ります。ボディをパースして再シリアライズすれば、キーの順序、空白、ユニコードのエスケープで署名は壊れます。だから Worker は生のテキストを保持し、何かが JSON.parse に触れる前に検証します。
Stripe の署名ヘッダーは複数の v1 署名を同時に持てます。シークレットのローテーションはそうやって動くので、検証器は 300 秒のリプレイ窓の中でそのどれとでも照合します。これは Stripe 自身のライブラリの既定値です。そして比較自体がタイミングを漏らしてはいけません。
/**
* Constant-time hex compare. The length check is acceptable: a digest's
* length is fixed by its algorithm, so a mismatch only ever means an
* invalid signature, not a secret-dependent branch.
*/
export function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false
let diff = 0
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
return diff === 0
}
どれも珍しいものではありません。どれもハッピーパスの連携が省くものそのもので、ここで省けば、偽造された Webhook があなたのリポジトリへのアクセスを付与できてしまいます。
段階的に届く返金
このエンジンの歴史で最高のバグは、実際の購入者に出会う前に見つかりました。プロバイダーは支払いを段階的に返金でき、エンジンには full_refund_only というポリシーがあります。部分返金は処理され、正しく取り消しをスキップします。
返金の Workflow ID はかつて {adapter}-refund-{txn} でした。後のイベントが返金を完了させたとき、それは同じトランザクションを持ち、同じ ID を組み立て、再送から守るはずの同じ重複排除がそれを静かに飲み込みました。全額返金された購入者がアクセスを保持する。まさにそのポリシーが存在する理由のケースです。
修正は ID 自体の中にあります。返金のインスタンス ID は今では返金の範囲を持ち、再送は引き続き重複排除され、同じ答え、同じ ID である一方、完了は partial を full に切り替え、新しい ID を生み、取り消しが実行されます。
const suffix = eventType === 'refund' ? `-${refundScopeOf(isFullRefund)}` : ''
const readable = `${adapter}-${eventType}-${transactionId}${suffix}`
そして isFullRefund は省略可能ではなく必須のパラメータになりました。これが本当の安全性です。範囲を忘れたエンキュー箇所は、古い形の ID を静かに生成しません。コンパイルが通らないのです。
教訓は返金の先まで届きます。冪等キーをイベントから導出するなら、キーはイベントが持つすべての答えを持たなければなりません。それより少ないもので重複排除すれば、2 つの異なる答えが 1 つに潰れます。
エージェントがデプロイし、あなたのシークレットは決して見ない
リポジトリにはセットアップウィザードが同梱されていて、その設計は多くの「AI 対応」セットアップ文書が使うパターンを逆転させています。信頼できるセットアップツール、wrangler login、gh auth login、create-next-app はどれも同じやり方で動きます。ツールが主導して状態を保持し、人間が答える。エージェントが即興で進めるための散文のガイドはその逆で、散文はテストされていない表面です。
だからウィザードはプログラムです。エージェントは 1 つのコマンド npm run wizard:drive を実行し、それが出力するレコードをそのまま表示し、答えを返します。次のステップを選ぶことも、シェルコマンドを組み立てることも、経路の外で診断することもありません。
レコードとは、選択肢が固定された質問か、ダッシュボードから読み取る名前付きの自由入力欄か、人間が done の一語で確認する手動ステップです。そして done は信じられるのではなく検証されます。ドライバーが実際の状態、Organization、チーム、トークン、デプロイ済み Worker の URL を確認できるところでは確認し、失敗した確認は誤った入力を持つ画面へ戻り、既知の失敗パターンがデータとして添えられます。実行は実際のエンドツーエンドのテスト購入で終わるので、「動く」は希望ではなく観察です。
私が最も大事にしているのは、エージェントが見られないものです。シークレットの値はあなた自身が .dev.vars に貼り付けます。デプロイはそのファイルを wrangler に渡し、wrangler が直接読むので、値はエージェントを通りません。エージェントによるシークレットファイルの読み取りを名前で禁じる権限ルールはリポジトリにコミットされていて、テストが偽のシークレットを仕込み、拒否されることを証明します。何かを実行する前に、その強制の仕組みを読めます。
ダッシュボードの作業を含めて、最初から最後までおよそ 1 時間を見てください。ウィザードはエージェントを選ばず、最先端のモデルも必要としません。順序と文言はドライバーが持ち、エージェントは表示と中継だけを担います。私自身の実運用では、Claude Code の最も安価な段階である Haiku と、OpenCode の Big Pickle のような無料同梱モデルを使いました。
AGPL のコアに含まれるもの、Pro が加えるもの
分け方は機能制限ではなく、必要に応じたものです。npm のコアは完全なエンジンです。Webhook ルーター、Stripe アダプター、付与と取り消しの Workflow、クレームページ、タイポの経路、そしてウィザード。Stripe で販売していて自分でホストするのに抵抗がなければ、コアが製品のすべてで、それはこの先も変わりません。
コアはさらに、公開の Webhook ルートをまったく持たずに、サービスを RPC であなた自身の Cloudflare のファネルに組み込むために必要なすべてのプリミティブをエクスポートします。Pro はそれを完成済みでサポート付きのサービスクラスとして提供し、コアは自分で組み立てるための部品を渡します。
Pro は 2 種類の販売者のためにあります。プロバイダーが Stripe ではない人と、Webhook のメンテナンスを自分で抱えたくない人です。残り 5 つのアダプター、Paddle、Lemon Squeezy、Gumroad、Razorpay、Telegram Stars を加え、そのうち Paddle と Lemon Squeezy は、税務とコンプライアンスをプロバイダーに担わせたい販売者向けの Merchant of Record の選択肢です。テーマ付きのストアフロントと購入者向けページも同梱され、メンテナンスされます。プロバイダーは時間とともに Webhook のペイロード、イベント名、署名方式を変えていきます。それを追いかけ続けることこそ、あなたがお金を払って手放す仕事です。
価格は買い切りで、12 か月分のアップデートとサポートを含みます。更新しなければ、手元のバージョンは永久に動き続けます。クローンもデプロイ済みの Worker もあなたのもので、確認のためにどこかへ電話をかけるものは何もありません。
判断の目安をできるだけ平たく言えば、Stripe プラス自前ホスティングならコア。それ以外のプロバイダー、Merchant of Record、あるいは誰かに最新に保ってほしいファネルなら、そのために Pro があります。
引き換えに手放すもの
OAuth なしということは、ユーザー名は手入力で、手入力は間違えられるということです。恐怖より上限が重要です。すべての招待には完了した購入が必要なので、誰もアクセスを収穫することはできず、残るのは 1 人の購入者の 1 つのミス、1 つのアカウント、1 つの購入で、取り消し可能です。タイポの経路は検出可能な半分を捕まえ、他人の実在アカウントに一致する打ち間違いはイベントログで答えられるものであって検出できるものではありません。持っていない検出を約束するより、そう率直に書きます。
自前ホスティングは取引のもう半分です。Worker はあなたの Cloudflare アカウント上で、あなたの GitHub Organization とプロバイダーのダッシュボードに対して動きます。設定に誤りがあれば、ウィザードの検証とイベントの流れが教えてくれますが、指をさせるベンダーのステータスページはありません。マージンを持つことと、ポケベルを持つことは同じ決断です。
そして無料枠には天井があります。KV の 1 日 1,000 回の書き込みは 1 日数百件の販売に相当し、コード製品の平常時をはるかに上回りますが、ローンチ日のスパイクを上回るとは限りません。月 5 ドルの Workers Paid プランは書き込み枠を月 100 万回に引き上げ、そこで意味のある制約ではなくなります。
どこから始めるか
Stripe で販売しているなら、repoaccess-core をクローンし、すでに使っているコーディングエージェントでウィザードを実行し、およそ 1 時間を見てください。README にはこの記事がたどったのと同じアーキテクチャが、強制の仕組みの詳細とともにコードの隣に書かれています。
プロバイダーが Paddle、Lemon Squeezy、Gumroad、Razorpay、Telegram Stars のいずれかなら、あるいはメンテナンスを任せたいなら、有料版は RepoAccess の製品ページにあります。
最後に一つ、このものを信頼することについて私が言える最も強い事実を。Pro のすべてのコピーは RepoAccess 自身によって配信されます。あなたが支払うと、Worker があなたの GitHub アカウントをプライベートリポジトリに招待し、返金があればその招待を取り消します。配信の層は、他のすべてを売るのと同じやり方で、自分自身を売っています。
よくある質問
SaaS プラットフォームを挟まずに、プライベート GitHub リポジトリのアクセス権を販売するにはどうすればいいですか?
配信の層を自分で動かします。RepoAccess はオープンソース (AGPL-3.0) の Cloudflare Worker で、あなた自身のアカウントにデプロイします。決済プロバイダーが Worker に Webhook を POST し、Worker は生のボディに対して署名を検証し、商品を GitHub チームに対応づけて、購入者のユーザー名を招待します。返金やチャージバックは同じ経路でアクセスを取り消します。チェックアウトはすでに使っているプロバイダーに置いたまま。Stripe アダプターはコアに含まれ、決済事業者自身の手数料の上に販売ごとの手数料が乗ることはありません。
返金やチャージバックのあと、RepoAccess はどうやって GitHub のアクセスを取り消しますか?
すべてのイベントが安定したトランザクション ID を持つので、注文の数週間後に届く返金も、その付与と対応づけられます。取り消しは Cloudflare Workflow の耐久性のあるステップとして実行され、保存済みのフラグではなく GitHub の実際の状態との突き合わせで動きます。購入者はチームから外され、すでに消えているメンバーシップは処理済みとして読まれます。この突き合わせの性質が、プロバイダーの再送を無害にしています。
RepoAccess が購入者に Login with GitHub (OAuth) を使わないのはなぜですか?
可動部品の一群と、ファネル上のコストをまるごと取り除けるからです。OAuth がなければ、登録するアプリも、コールバックのルートも、セッションストアも、静かに期限切れになる追加のシークレットもありません。Worker は購入者の身元を一切保存しません。購入者は GitHub のユーザー名を入力してメールの招待を受け入れるだけなので、支払い直後、つまり摩擦を足すのが最も高くつく瞬間に、何も割り込みません。
購入者がチェックアウトで GitHub のユーザー名を打ち間違えたらどうなりますか?
ハンドルが使えない場合、つまり空、形式が不正、または GitHub に存在しないアカウント名の場合、Worker はトランザクションに紐づいた使い捨てのクレームトークンを発行し、購入者はクレームページで手続きを終えます。ユーザー名を入力し、確認し、招待を受け取る、という流れです。他人の実在アカウントに一致してしまう打ち間違いはどのシステムにも検出できません。検出を約束する代わりに、すべての access.granted イベントがトランザクションとハンドルの両方を持つので、サポートのチケットは一目で解決します。
RepoAccess はデータベースなしでどうやって Webhook の再送を重複排除しますか?
Cloudflare Workflow のインスタンス ID をイベント自体から導出します。アダプター、イベント種別、トランザクション ID、そして返金の場合は返金の範囲です。プロバイダーが再送すると同じ ID が組み立てられ、Workflows のランタイムが重複を静かにスキップします。処理済みイベント ID の表を維持する必要はありません。返金範囲のサフィックスが重要で、段階的に支払われた返金は部分から全額に切り替わって新しい ID を生み、取り消しは重複排除に飲み込まれずに実行されます。
Cloudflare の無料枠で RepoAccess は 1 日に何件の販売を処理できますか?
数百件です。Worker が触れる無料枠の最も厳しい制限は Workers KV の 1 日 1,000 回の書き込みで、1 回の販売は数回の書き込みです。リクエストの上限は 1 日 100,000 回とはるかに高く設定されています。それを超えると、月 5 ドルの Workers Paid プランで KV の枠は月 100 万回の書き込みになり、コード製品にとってスループットは意味のある制約ではなくなります。
無料の RepoAccess Core と RepoAccess Pro の違いは何ですか?
npm の AGPL コアは、Stripe で販売する人にとって完全なエンジンです。Webhook ルーター、Stripe アダプター、付与と取り消しの Workflow、クレームページ、セットアップウィザード、そしてサービスを RPC で自分の Worker に組み込むためのプリミティブが含まれます。Pro は残り 5 つのアダプター、Paddle、Lemon Squeezy、Gumroad、Razorpay、Telegram Stars を加え、そのうち 2 つは Merchant of Record の選択肢です。さらにテーマ付きのストアフロントと継続的なメンテナンスとサポートが付き、買い切りで 12 か月分のアップデートが含まれます。
コーディングエージェントは RepoAccess を単独でデプロイできますか?
できます。しかも偶然ではなく設計によってです。セットアップウィザードはエージェントが 1 つのコマンドで進める状態機械で、各レコードをそのまま表示し、回答を中継し、done のたびに Organization、チーム、トークン、デプロイ済み URL といった実際の状態と照合し、失敗すれば誤った入力を持つ画面へ戻します。シークレットはエージェントを通りません。wrangler がシークレットのファイルを自分で読み、エージェントにその読み取りを禁じる権限ルールはリポジトリにコミットされています。安価なモデルでおよそ 1 時間を見てください。実際の実行では Claude Code の Haiku と、OpenCode の無料同梱モデルを使いました。
プライベートリポジトリのアクセス権を販売するのに GitHub Pro や GitHub Team は必要ですか?
不要です。RepoAccess は個人アカウントのコラボレーターではなく GitHub Organization のチームで動き、無料の Organization で足ります。プライベートリポジトリとチームはそこに含まれています。必要なのは、販売するリポジトリを所有する Organization、商品ごとのチーム、そしてその Organization に対する権限を持つトークンです。本当に効いてくる制限はコラボレーターの数ではなく Workers KV の書き込み枠で、それは 1 日数百件の販売に足ります。