Skip to main content
When a customer purchases Vibestrap, the source isn’t shipped as a downloadable zip. Instead, they enter their GitHub username on /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.
[Buyer enters GitHub username] → button click

sendGitHubInviteAction (src/actions/send-github-invite.ts)
  ├─ next-safe-action validates the input shape
  ├─ Gate: SELECT FROM payment WHERE userId=? AND status='paid'
  │                              AND scene IN (product.id, 'vibestrap-lifetime')
  ├─ Returns { ok: false, reason: 'not_paid' } if no row
  └─ Calls inviteCollaborator(username) ↓
                                          inviteCollaborator (src/github/invite.ts)
                                          ├─ PUT /repos/{owner}/{repo}/collaborators/{username}
                                          │  with { "permission": "pull" }
                                          ├─ Translates GitHub status codes:
                                          │    201 → invited
                                          │    204 / 422 → already_invited
                                          │    404 → invalid_username
                                          │    401 / 403 → forbidden
                                          │    429 → rate_limited
                                          └─ Returns InviteResult union
The buyer sees a Sonner toast based on the result reason; the page is unchanged otherwise.

One-time setup

1. Use an Organization repo (not a personal one)

GitHub’s permission: '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:
# Optional: keep dev separate from buyer-facing
git push --mirror git@github.com:YourOrg/vibestrap.git
Set Vibestrap’s config:
// src/config/site.ts
github: {
  inviteRepoOwner: 'YourOrg',     // ← the Organization name
  inviteRepoName: 'vibestrap',    // ← the private repo
}

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 /repos once 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 collaborator endpoint 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.
Copy the token (you only see it once) and paste it into your prod env:
GITHUB_INVITE_TOKEN=github_pat_11ABCDEF...
The token is read by src/github/invite.ts at request time — no rebuild needed when you rotate it.

4. Verify locally

# In your dev shell
GITHUB_INVITE_TOKEN=ghp_xxx pnpm dev

# In another shell
curl -X PUT \
  -H "Authorization: Bearer $GITHUB_INVITE_TOKEN" \
  -H "Accept: application/vnd.github+json" \
  https://api.github.com/repos/YourOrg/vibestrap/collaborators/your-test-account \
  -d '{"permission":"pull"}'
201 = success, 204 = already a collaborator, 404 = wrong username, 403 = token under-scoped.

Buyer flow on the live site

  1. Customer signs up, pays via Stripe.
  2. Webhook writes a payment row with status='paid' and scene matching siteConfig.product.id.
  3. They navigate to /dashboard — the page detects the paid row and renders the buyer view, which embeds the invite form.
  4. They enter their GitHub username and click Send invite.
  5. The server action checks the payment row, calls GitHub, and returns a result reason. A Sonner toast surfaces it.
  6. They open GitHub email, accept the invitation, then:
    git clone git@github.com:YourOrg/vibestrap.git
    
If they ever miss the email or change usernames, they re-enter and click again. The invite is idempotent server-side.

Rotation runbook

Every 60 days (before the 90-day expiration emails from GitHub start):
  1. Mint a new PAT with the same scope.
  2. Update prod env (GITHUB_INVITE_TOKEN).
  3. Redeploy.
  4. Do a smoke test: invite a test account.
  5. 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.scene matches siteConfig.product.id exactly (or the 'vibestrap-lifetime' historical alias). Check via SQL.
  • Buyer reports “invalid_username” — they probably typed the wrong thing. Or copied a @username with 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 return remote 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 a license table and LICENSE_DOWNLOAD_URL env:
  • Both are gone. The schema migration drops license; the env var is removed; /api/license/download was deleted.
  • The payment-table check in sendGitHubInviteAction replaces the license-row check.
  • Old buyers (if any) — re-issue invites manually via curl or have them request again from /settings/purchases.