/settings/purchases and
get invited as a read-only (pull) collaborator on your private Org repo.
They accept the invite, git clone, and from then on git pull brings them
every release you push.
This is deliberately not a “License key + signed download URL” flow. Source
zips age out the moment you ship a hotfix; a GitHub invite ages with you.
How the gate works
Three layers, deliberately decoupled. The whole flow has exactly one business rule: the user must have a paid Vibestrap purchase. Everything else is plumbing.One-time setup
1. Use an Organization repo (not a personal one)
GitHub’spermission: 'pull' flag is silently ignored on personal repos
— every collaborator gets push access regardless. You must host the
buyer-facing repo in an Organization.
If your dev work happens on a personal repo, treat the Org repo as a
release mirror:
2. Lock down the Org
In your Org settings → Member privileges:- Uncheck “Allow members to delete or transfer repositories” — even an
Administration:write token can’t
DELETE /reposonce this is off. - Set Default repository permission to
None. - Optionally disable forking of private repos so buyers can’t fork-and-leak.
3. Mint a fine-grained PAT
Go to https://github.com/settings/personal-access-tokens (logged in as the account that’s an Org member, ideally a dedicated bot account):- Resource owner: your Org
- Repository access: only the buyer-facing repo
- Permissions:
- Metadata: Read-only (required by GitHub)
- Administration: Read and write (the
Add a collaboratorendpoint requires this — it’s the narrowest scope GitHub offers for the operation; the Org-level “no delete” lock is what protects you from misuse)
- Expiration: 1 year. Calendar a 60-day rotation reminder.
src/github/invite.ts at request time — no rebuild
needed when you rotate it.
4. Verify locally
Buyer flow on the live site
- Customer signs up, pays via Stripe.
- Webhook writes a
paymentrow withstatus='paid'andscenematchingsiteConfig.product.id. - They navigate to
/dashboard— the page detects the paid row and renders the buyer view, which embeds the invite form. - They enter their GitHub username and click Send invite.
- The server action checks the payment row, calls GitHub, and returns a result reason. A Sonner toast surfaces it.
- They open GitHub email, accept the invitation, then:
Rotation runbook
Every 60 days (before the 90-day expiration emails from GitHub start):- Mint a new PAT with the same scope.
- Update prod env (
GITHUB_INVITE_TOKEN). - Redeploy.
- Do a smoke test: invite a test account.
- Once you’re confident the new token works, revoke the old one in https://github.com/settings/personal-access-tokens.
Troubleshooting
- Buyer reports “not_paid” toast despite paying — confirm the
payment.scenematchessiteConfig.product.idexactly (or the'vibestrap-lifetime'historical alias). Check via SQL. - Buyer reports “invalid_username” — they probably typed the wrong
thing. Or copied a
@usernamewith the@prefix. Validate locally. - All invites fail with “forbidden” — the PAT expired or was revoked. Mint a new one.
- Invite hits the buyer’s GitHub but they never see push access —
that’s the point:
permission: 'pull'on an Org repo gives them clone rights and nothing else. Push attempts returnremote rejected. - Why no zip download? — Source zips don’t track future releases.
Buyers want
git pull, not a tarball that ages out the day after they download it. (And implementing a working signed-URL download flow takes the same effort as the GitHub invite, with a worse buyer experience.)
Migration from the old license-key flow
If you previously ran alicense table and LICENSE_DOWNLOAD_URL env:
- Both are gone. The schema migration drops
license; the env var is removed;/api/license/downloadwas deleted. - The payment-table check in
sendGitHubInviteActionreplaces the license-row check. - Old buyers (if any) — re-issue invites manually via
curlor have them request again from/settings/purchases.
Source links
- API client:
src/github/invite.ts - Server action:
src/actions/send-github-invite.ts - UI:
src/app/[locale]/(app)/settings/purchases/ - Tests:
tests/unit/github-invite.test.ts