> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vibestrap.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# GitHub invite delivery

> How buyers get the source — payment-gated, read-only collaborator invites to your private Org repo.

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:

```bash theme={null}
# Optional: keep dev separate from buyer-facing
git push --mirror git@github.com:YourOrg/vibestrap.git
```

Set Vibestrap's config:

```ts theme={null}
// 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](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:

```bash theme={null}
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

```bash theme={null}
# 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:
   ```bash theme={null}
   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](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`.

## Source links

* API client: [`src/github/invite.ts`](https://github.com/xiaohu0x/vibestrap/blob/main/src/github/invite.ts)
* Server action: [`src/actions/send-github-invite.ts`](https://github.com/xiaohu0x/vibestrap/blob/main/src/actions/send-github-invite.ts)
* UI: [`src/app/[locale]/(app)/settings/purchases/`](https://github.com/xiaohu0x/vibestrap/tree/main/src/app/%5Blocale%5D/\(app\)/settings/purchases)
* Tests: [`tests/unit/github-invite.test.ts`](https://github.com/xiaohu0x/vibestrap/blob/main/tests/unit/github-invite.test.ts)
