repoaccess-core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,422 @@
1
+ ---
2
+ title: RepoAccess Worker - Setup Guide (for sellers)
3
+ type: guide
4
+ audience: buyers / OSS users deploying their own worker
5
+ status: draft
6
+ created: 2026-06-14
7
+ note: >-
8
+ Draft of the customer-facing setup guide. Will become the OSS README / doc-site onboarding,
9
+ and is the seed corpus for the future support-agent (RAG). Generic - contains NO EdgeKits
10
+ secrets or account IDs. Re-verify each provider's dashboard steps at publish time.
11
+ ---
12
+
13
+ # Deliver GitHub repo access on payment - setup guide
14
+
15
+ This guide gets your **self-hosted RepoAccess Worker** live: a buyer pays through your payment
16
+ provider, and they're automatically added to the right GitHub team (which carries access to your
17
+ private repo). Runs on the **Cloudflare Workers free tier** - no server, no SaaS fees.
18
+
19
+ **Target: ~10 minutes** once you have the accounts below.
20
+
21
+ ## Prerequisites
22
+
23
+ - A **Cloudflare account** (free) + the `wrangler` CLI (`npm i -g wrangler`, then `wrangler login`).
24
+ - A **GitHub organization** that owns the private repo(s) you sell.
25
+ - An account with a **webhook-capable payment provider** (Stripe, Paddle, Gumroad, Lemon Squeezy, …).
26
+ - Node.js + npm.
27
+
28
+ ---
29
+
30
+ ## Step 1 - Get the worker
31
+
32
+ Install the core (or, for the Pro adapters, use your Pro build):
33
+
34
+ ```bash
35
+ npm install repoaccess-core
36
+ ```
37
+
38
+ Your entry composes the adapters you use and passes your typed config. It exports the three things the
39
+ Cloudflare runtime needs - the worker (`fetch`), the Workflow class, and the claim-guard Durable Object:
40
+
41
+ ```ts
42
+ // src/index.ts
43
+ import { createWorker, createAccessWorkflow, ClaimGuard } from 'repoaccess-core'
44
+ import { stripe } from 'repoaccess-core/adapters/stripe'
45
+ import { config } from './repoaccess.config'
46
+
47
+ // Pass the SAME adapter list to both: createWorker (verifies + acks the webhook) and
48
+ // createAccessWorkflow (the Workflow that grants/revokes - and, for api_callback adapters, fetches
49
+ // the authoritative entity). Keep the two lists identical.
50
+ const adapters = [stripe]
51
+
52
+ export default createWorker({ adapters, config })
53
+ export class AccessWorkflow extends createAccessWorkflow(config, adapters) {}
54
+ export { ClaimGuard }
55
+ ```
56
+
57
+ Your settings live in a typed `repoaccess.config.ts` (Step 5) - no escaped-JSON env vars. Secrets stay
58
+ in the runtime env (Step 6).
59
+
60
+ ## Step 2 - Cloudflare bindings
61
+
62
+ In `wrangler.jsonc`: add a **Workflows** binding (`AccessWorkflow`), a **KV namespace**
63
+ (`ENTITLEMENTS`), and a **Durable Object** binding (`CLAIM_GUARD`). There are **no `vars`** - your
64
+ non-secret config lives in `repoaccess.config.ts` (Step 5), not in `wrangler.jsonc`. Create the KV
65
+ namespace env-correctly:
66
+
67
+ ```bash
68
+ # sandbox / dev -> title <worker-name>-ENTITLEMENTS
69
+ wrangler kv namespace create ENTITLEMENTS
70
+ # production -> title <worker-name>-production-ENTITLEMENTS
71
+ wrangler kv namespace create ENTITLEMENTS --env production
72
+ ```
73
+
74
+ The namespace **title** follows an env-aware convention derived from the worker name -
75
+ `<worker-name>-ENTITLEMENTS` (sandbox) or `<worker-name>-production-ENTITLEMENTS` (production) - which is
76
+ what `wrangler` produces when the create picks up the worker `name` and `--env`. The **binding stays
77
+ `ENTITLEMENTS`** (the code reads `env.ENTITLEMENTS`; never rename the binding). Verify the created title
78
+ carries the worker prefix - if it came out as a bare `ENTITLEMENTS`, the convention did not apply;
79
+ recreate or rename it before wiring. Then paste the returned id into `wrangler.jsonc` for the matching
80
+ env. (Workflows are available on the free plan.)
81
+
82
+ > **The Workflow `name` must be unique per worker.** A Workflow `name` is account-global and belongs
83
+ > to exactly one worker. If you run more than one RepoAccess worker on the same Cloudflare account
84
+ > (e.g. a Pro worker alongside this one, or separate staging/production deploys), give each a
85
+ > **distinct** workflow `name` - otherwise the later deploy silently reassigns the Workflow and breaks
86
+ > the other worker's binding.
87
+
88
+ ## Step 3 - GitHub
89
+
90
+ 1. **Teams = product tiers.** In your org, create a team per tier (e.g. `pro`). Buyers get added here.
91
+ 2. **Attach your private repo(s) to the team** (Team → Repositories → Add). The team carries repo
92
+ access - the worker never adds direct collaborators.
93
+ 3. **⚠️ Set org Base permissions to `No permission`** (Org → Settings → Member privileges → Base
94
+ permissions). This is the setting that makes per-product isolation actually work. Base permissions are
95
+ the floor that **every** org member gets to **every** repo in the org. If it is `Read` (or higher) -
96
+ and on many orgs the default is `Read` - then any buyer of any product can see all your private repos,
97
+ and team scoping buys you nothing. With `No permission`, members get access **only** through their
98
+ team(s), so buying product A (team A → repo A) grants repo A alone and other products' repos stay
99
+ invisible. Keep the repos **private** (paid repos already are).
100
+ 4. **Fine-grained PAT** (Settings → Developer settings → Fine-grained tokens):
101
+ - Resource owner = **your org** (enable fine-grained PATs in Org → Settings → Personal access tokens first).
102
+ - Repository access = **none** (the token only manages membership).
103
+ - Organization permissions → **Members: Read and write** (and nothing else).
104
+ - This token → secret `GITHUB_TOKEN` (Step 6). Your org slug goes in `config.githubOrg` (Step 5).
105
+ - ⚠️ New orgs cap invitations at **50/24h for the first month** - age the org before a big launch.
106
+
107
+ ### Harden the org (members = paying customers)
108
+
109
+ In this org, **"members" are buyers, not teammates** - treat them as untrusted and disable every member
110
+ privilege. Access comes **only** through team membership. Org **Owners keep full access regardless** -
111
+ these toggles restrict members, never you.
112
+
113
+ **Org → Settings → Member privileges** (each block has its own Save):
114
+
115
+ - **Base permissions → `No permission`** - the critical one (above).
116
+ - **Repository creation** → uncheck **Public and Private** (members don't create repos).
117
+ - **Repository forking** → off (no forking of private repos into member accounts).
118
+ - **Projects base permissions** → `No access`.
119
+ - **Pages creation** → uncheck **Public and Private**.
120
+ - **App access requests** → `Disable app access requests`.
121
+ - **GitHub Apps** ("Allow repository admins to install…") → off.
122
+ - **Admin repository permissions** → off for all: **Repository visibility change** (else a member-admin
123
+ could flip a private paid repo to **public**), **Repository deletion and transfer**, **Issue deletion**,
124
+ **Branch renames**.
125
+ - **Member team permissions → Team creation** → off.
126
+
127
+ **Org → Settings → Authentication security:**
128
+
129
+ - **Do NOT** "Require two-factor authentication for everyone" - it **removes** members without 2FA (your
130
+ buyers) and blocks them from accepting invites. Same goes for an IP allow list. Enable 2FA on your own
131
+ **owner** account instead.
132
+
133
+ **Org → Settings → Third-party Access:**
134
+
135
+ - **OAuth app policy** → keep **Access restricted** (approved apps only).
136
+ - **Personal access tokens → Fine-grained tokens** → **Allow access via fine-grained PATs** (the worker's
137
+ `GITHUB_TOKEN` needs this; "Restrict" breaks grants). Optionally **Require administrator approval** for
138
+ members' tokens.
139
+ - **Token expiry** → leave **"Fine-grained PATs must expire"** on (good hygiene) - which means the worker
140
+ token expires too; see rotation below.
141
+
142
+ **Token rotation (operational).** The worker's `GITHUB_TOKEN` is a fine-grained PAT and **will expire**.
143
+ Before it does: issue a new token with the same scope → update the `GITHUB_TOKEN` secret
144
+ (`wrangler secret`) → re-approve it if approval is required. GitHub emails a warning before expiry; set a
145
+ calendar reminder too. If it lapses, grants/revokes stop until you rotate.
146
+
147
+ **Optional - Discussions as a feedback channel.** Enabling "Allow users with read access to create
148
+ discussions" gives buyers a built-in Q&A/feedback space. Note: discussions in a **private** repo are
149
+ visible to **everyone with access to that repo** (your other buyers) - a shared space, not private 1:1
150
+ support.
151
+
152
+ ## Step 4 - Your payment provider
153
+
154
+ The pattern is the same for every provider:
155
+
156
+ 1. **Create your product + price** in the provider's dashboard. Note the **product id**.
157
+ 2. **Create a webhook** pointing at your worker:
158
+ `https://<your-worker-url>/wh/<adapter>/<SECRET_PATH>`
159
+ - `<adapter>` = `stripe` / `paddle` / `gumroad` / … ; `<SECRET_PATH>` = a random string you choose
160
+ (`node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"`) - a hard-to-guess URL
161
+ segment for obscurity. It lives **only in this
162
+ webhook URL** (and your own notes) - it is **not** a worker secret: signature verification is the
163
+ real gate, so the worker doesn't validate the path. (Signature-less adapters like Gumroad are the
164
+ exception - there the path is a checked credential; see the per-adapter notes.)
165
+ - Subscribe only to the events your adapter needs (purchase + refund/chargeback). See the
166
+ per-adapter notes.
167
+ 3. **Copy the webhook signing secret** → `<ADAPTER>_WEBHOOK_SECRET`.
168
+
169
+ Per-provider specifics (events, signature scheme, gotchas) are in the per-adapter reference. Quick
170
+ examples:
171
+
172
+ - **Stripe:** events `checkout.session.completed`, `charge.refunded`, `charge.dispute.created`;
173
+ signing secret `whsec_…`. (You can also test locally with the Stripe CLI.) Set
174
+ **`metadata.product_id`** (and, for username mode, **`metadata.github_username`**) on your Checkout
175
+ Session / Payment Link - Stripe's checkout webhook omits line items, so the worker reads the
176
+ product from metadata; if it's missing, the buyer falls through to your `defaults` mapping.
177
+ _No-code Payment Link:_ the link _builder_ has no metadata field, but the **created link's detail
178
+ page has an editable Metadata section** - add `product_id` there (Stripe copies a Payment Link's
179
+ metadata onto every Checkout Session it creates). One-time per link.
180
+ - **Paddle:** events `transaction.completed`, `adjustment.created`, `adjustment.updated`; one-time
181
+ prices only (no subscription events); destination secret from the notification destination. **No
182
+ native buyer-input checkout field** - pass `github_username` as seller `customData` or use `claim`
183
+ mode (see Step 5).
184
+ - **Lemon Squeezy:** events `order_created`, `order_refunded`; `X-Signature` HMAC-SHA256 over the raw
185
+ body (no timestamp); signing secret is one **you choose** when creating the webhook (6–40 chars).
186
+ **No native buyer-input checkout field** - pass `github_username` as seller `custom_data`
187
+ (`checkout[custom][github_username]=…`) or use `claim` mode (see Step 5). Chargebacks: LS's
188
+ `dispute_*` events are undocumented and not yet handled, so `auto_revoke` covers refunds only -
189
+ revoke a chargeback manually for now.
190
+ - **Gumroad:** **no webhook signature at all** (ignore any `x-gumroad-signature` - it doesn't exist).
191
+ Here the **secret path segment IS a validated credential**, so it's a real secret
192
+ (`GUMROAD_WEBHOOK_PATH`), not just obscurity. Register `sale` / `refund` / `dispute` via
193
+ `PUT /v2/resource_subscriptions` pointed at `…/wh/gumroad/<path>`. The worker confirms every event by
194
+ calling `GET /v2/sales/{id}` with your **`GUMROAD_ACCESS_TOKEN`** - so that token is required too.
195
+ `github_username` via a Gumroad **custom field** the buyer fills at checkout (Gumroad has a native
196
+ buyer-input field), or use `claim` mode.
197
+
198
+ ## Step 5 - Configure (`repoaccess.config.ts`)
199
+
200
+ All non-secret config is a **typed object** you author - full editor autocomplete, real comments, no
201
+ escaped-JSON env vars:
202
+
203
+ ```ts
204
+ // src/repoaccess.config.ts
205
+ import type { RepoAccessConfig } from 'repoaccess-core'
206
+
207
+ export const config: RepoAccessConfig = {
208
+ githubOrg: 'your-org',
209
+ productTeamMap: {
210
+ // adapter → product_id → mapping
211
+ stripe: { prod_ABC: { teams: ['pro'], grant_mode: 'username' } },
212
+ // reserved fallback for any unmapped product (keep it neutral - see the warning)
213
+ defaults: {
214
+ teams: [],
215
+ grant_mode: 'claim',
216
+ revoke_policy: { mode: 'log_only' },
217
+ },
218
+ },
219
+ branding: { name: 'Acme', logoUrl: '', faviconUrl: '' }, // optional - claim-page look
220
+ // eventWebhook: { url: "https://you.example/events", allowlist: ["you.example"] }, // optional
221
+ }
222
+ ```
223
+
224
+ - **`productTeamMap`** is keyed **adapter → product_id → mapping**. The adapter key (`stripe`,
225
+ `paddle`, …) is the adapter's `name` and matches its webhook route `/wh/<adapter>/…` - there's no
226
+ separate "which provider" switch.
227
+ - **`grant_mode`:** `username` (use the buyer's GitHub username if your checkout collected it, else
228
+ falls back to `claim`) or `claim` (buyer enters their username on a claim page after paying).
229
+ - **`revoke_policy`:** `{ mode: "auto_revoke" }` (remove access on refund/chargeback) or
230
+ `{ mode: "log_only" }`. **Auto-revoke horizon:** the worker keeps each grant record for **180 days**,
231
+ so `auto_revoke` covers refunds and card chargebacks within that window (a typical refund window plus
232
+ the ~120-day chargeback window). A dispute after 180 days won't auto-revoke - handle it manually.
233
+
234
+ > **Chargebacks & disputes.** A chargeback (the buyer's bank reverses the charge, bypassing you) is
235
+ > handled separately from a refund. Under `auto_revoke` the worker revokes access **when the dispute
236
+ > is raised** - each adapter maps its provider's dispute-opened event to this (Stripe
237
+ > `charge.dispute.created`, Paddle a `chargeback` adjustment, Razorpay `payment.dispute.created`),
238
+ > while pre-dispute _early-warning_ signals are deliberately ignored. The worker **never auto-restores**
239
+ > access if you later win the dispute - re-grant manually (Team → Members → **Add a member**, or
240
+ > re-issue the claim link); it reconciles around manual changes. Per-provider event names and nuances
241
+ > live in each provider's reference.
242
+
243
+ > **Keep `defaults` empty / `log_only` unless you mean it.** Any product that isn't in the map - and
244
+ > any stray webhook from an adapter you composed - falls through to `defaults`. Empty `defaults.teams`
245
+ > is a safe no-op; a real team there becomes a **catch-all** that grants on anything. Only set a
246
+ > non-empty `defaults` if you genuinely want a catch-all tier.
247
+
248
+ ### Sandbox vs production (optional)
249
+
250
+ `env` isn't available at module load in Workers, so pick the profile **at build time**, not from a
251
+ runtime var. Two clean options:
252
+
253
+ - **Single env (simplest):** export one `config` and point both `createWorker` / `createAccessWorkflow`
254
+ at it (as in Step 1). Most deployers need only this.
255
+ - **Sandbox/prod split:** export two profiles from `repoaccess.config.ts` (e.g. `sandbox` and
256
+ `production` sharing a base), add a second tiny entry `src/index.production.ts` that imports
257
+ `production`, and set `[env.production].main = "src/index.production.ts"` in `wrangler.jsonc`.
258
+ `wrangler deploy` then uses the sandbox profile; `wrangler deploy --env production` uses the prod one.
259
+
260
+ ### Collecting the buyer's GitHub username
261
+
262
+ The buyer always supplies their own username - you never type it per-buyer. Two ways, matching the
263
+ two `grant_mode`s:
264
+
265
+ - **At checkout (`username` mode).** Add a **custom field** labelled e.g. "GitHub username" to your
266
+ checkout, so the buyer fills it in while paying. \*\*A native buyer-input field is provider-dependent
267
+ - check yours:\*\*
268
+ - _Stripe Payment Link / Checkout:_ Dashboard → your Payment Link → **Custom fields** → add a text
269
+ field (or set `custom_fields` when creating the Checkout Session). The worker reads it from the
270
+ `checkout.session.completed` event.
271
+ - _Gumroad:_ add a **custom field** to the product (Gumroad has a native buyer-input field) named
272
+ `github_username`; the worker reads it from the fetched sale entity.
273
+ - _Providers with no buyer-input field (e.g. Paddle, Lemon Squeezy):_ some hosted checkouts have
274
+ **no field the buyer can type into** - their "custom data" is **seller-passed**, meant for values
275
+ you already know before checkout, not buyer input. Paddle: `customData` via Paddle.js /
276
+ `data-custom-data` / the API. Lemon Squeezy: a `checkout[custom][github_username]=…` URL parameter
277
+ or the API. On these, either use **`claim` mode** (the natural fit), or collect the username on
278
+ **your own page before checkout** and pass it through as that seller-passed custom data.
279
+ - _Your own checkout page (any provider):_ collect the username yourself and pass it through as the
280
+ provider's custom-data/metadata field (Stripe `metadata.github_username`, Paddle `customData`,
281
+ LS `checkout[custom][github_username]`).
282
+ - **After checkout (`claim` mode).** Collect nothing at checkout. The buyer receives a one-time claim
283
+ link after paying and enters their username on the claim page. Use this if you can't (or don't want
284
+ to) add a checkout field. `username` mode automatically falls back to `claim` when no username is
285
+ present.
286
+
287
+ **What if the buyer mistypes it?** The worker never grants access directly - GitHub sends an
288
+ **invitation that only the real owner of that account can accept**. A wrong or non-existent handle
289
+ just means the invite is never accepted (the buyer contacts you to fix it); it can't silently let the
290
+ wrong person in. There's deliberately **no "Login with GitHub"** step - the invite-acceptance is the
291
+ ownership check, and the worker never phones home.
292
+
293
+ ## Step 6 - Secrets
294
+
295
+ **Only secrets** go in the runtime env - everything non-secret is in `repoaccess.config.ts` (Step 5).
296
+ Local dev → `.dev.vars`; production → `wrangler secret put <NAME>` (add `--env production` for a
297
+ separate prod environment):
298
+
299
+ ```
300
+ GITHUB_TOKEN # the fine-grained PAT (Step 3)
301
+ <ADAPTER>_WEBHOOK_SECRET # the provider's signing secret (Step 4) - one per adapter
302
+ EVENT_WEBHOOK_SECRET # optional - signs outbound events (32-byte hex; same node one-liner with 32)
303
+ ```
304
+
305
+ Not secrets, so **not** here (they're in `repoaccess.config.ts`): `githubOrg`, the product map,
306
+ branding, and `eventWebhook` (url + allowlist). And `<ADAPTER>_WEBHOOK_PATH` isn't a secret either -
307
+ your path segment lives in the webhook URL (Step 4), not the env (HMAC adapters don't validate it).
308
+ Never commit `.dev.vars` (git-ignored by default).
309
+
310
+ ## Step 7 - Deploy
311
+
312
+ ```bash
313
+ wrangler deploy --secrets-file .dev.vars
314
+ ```
315
+
316
+ (`secrets.required` in `wrangler.jsonc` gates the deploy, so upload the secrets inline the first time.
317
+ For a separate prod env: `wrangler deploy --env production --secrets-file .dev.vars.production`.)
318
+
319
+ Take the resulting `https://<worker>.workers.dev` URL and make sure your provider's webhook points at
320
+ `…/wh/<adapter>/<SECRET_PATH>`. (Optional: put it on a custom domain via `wrangler` routes.)
321
+
322
+ ## Step 8 - Test it
323
+
324
+ Do a **sandbox/test purchase** in your provider. You should see:
325
+
326
+ - the buyer added to the right team (username mode), **or** a `claim.pending` event with a claim
327
+ link (claim mode) → buyer opens it, enters their GitHub username, gets added;
328
+ - a refund/chargeback (if `auto_revoke`) removes them and cancels any pending invite.
329
+
330
+ `wrangler tail` shows structured logs of exactly what the worker did and why.
331
+
332
+ ## Troubleshooting
333
+
334
+ - **Signature verification fails:** the worker must see the raw request body byte-for-byte. If you
335
+ proxy/transform requests, don't re-serialize the body before it reaches the worker.
336
+ - **Buyer not added:** check `config.productTeamMap` has the exact provider `product_id`; check the
337
+ PAT has `Members: Read & write` on the org; check the team is attached to the repo.
338
+ - **`429` from GitHub:** invitation rate limit - the worker backs off and retries automatically;
339
+ if you're launching, age the org first.
340
+ - **Webhook never arrives:** verify the URL + secret path; re-send from the provider's dashboard
341
+ (the endpoint is idempotent - replays are safe).
342
+ - **`wrangler tail` shows `Exception Thrown` / `Canceled` on a run that actually succeeded:** expected
343
+ Cloudflare Workflows behavior, not a failure. The durable engine surfaces step
344
+ suspension/checkpointing and instance lifecycle as `exception` / `Canceled` _outcomes_ in tail (and
345
+ batches a step's logs until the instance finishes), even when everything worked. Judge success by the
346
+ **instance Status** in the Workflows dashboard (`Completed` vs `Errored`) and by the **emitted
347
+ events** (`access.granted` / `access.revoked` / `claim.pending`; a real failure emits `access.failed`
348
+ and ends `Errored`) - not by raw tail exception lines.
349
+
350
+ ## A buyer says they didn't get access
351
+
352
+ Most "I paid but I'm not in" tickets are an **unaccepted invite** or a **mistyped username** - both
353
+ are quick to resolve. Work down this list.
354
+
355
+ **First, tell the buyer to check for the invite.** Adding someone to a team sends a GitHub
356
+ **invitation** they must accept - access isn't active until they do.
357
+
358
+ 1. Check email (incl. spam) for a GitHub invite to your org, **and** the dashboard:
359
+ `https://github.com/orgs/<your-org>` shows a pending-invitation banner; the org invite link is
360
+ `https://github.com/orgs/<your-org>/invitation`.
361
+ 2. Accept it → they land in the team that carries the repo. Done in the common case.
362
+
363
+ **If there's no invite, diagnose on your side:**
364
+
365
+ 1. **Read the event / logs.** Your outbound events tell you exactly what happened:
366
+ `access.granted` (it worked - it's an unaccepted invite, see above), `access.failed` with a
367
+ `reason` (e.g. user not found, rate-limited), or `claim.pending` (claim mode - they never opened
368
+ the link). No event store? Run `wrangler tail` and replay the provider's webhook.
369
+ 2. **`access.failed → user not found`** = mistyped/non-existent username. Cancel any stale pending
370
+ invite (Org → People → **Pending invitations**), then re-collect: re-send the claim link, or just
371
+ invite them by hand (Team → Members → **Add a member**).
372
+ 3. **`access.failed → rate-limited` (`429`)** = new-org invite cap (50/24h in the first month). The
373
+ worker backs off and retries automatically - it'll land within the window. For a launch, age the
374
+ org first.
375
+ 4. **`claim.pending`, never completed** = the buyer didn't finish the claim page. Claim links are
376
+ **single-use and expire after 30 days** - re-issue a fresh link if theirs lapsed.
377
+ 5. **No event at all** = the webhook didn't arrive or didn't verify. Check the provider sent it
378
+ (re-send from its dashboard), the URL + secret path are right, and signature verification passed
379
+ (a transformed/re-serialized body breaks the HMAC - see above).
380
+
381
+ **Fastest manual override (any case):** as an org owner, add them directly - Team → Members → **Add a
382
+ member** → their GitHub username. The worker reconciles around manual changes, so this won't conflict
383
+ with it.
384
+
385
+ > **Why there's no "Login with GitHub":** the worker never logs the buyer in or phones home. The
386
+ > GitHub invitation _is_ the identity check - only the real owner of an account can accept it - which
387
+ > is why a wrong username is harmless (the invite simply sits unaccepted) and why fixing one is just
388
+ > re-issuing the invite to the correct handle.
389
+
390
+ ---
391
+
392
+ > _This is a draft. Final wording lives in the OSS README / doc-site; it also seeds the support
393
+ > agent's knowledge base._
394
+
395
+ <!--
396
+ SUPPORT CATALOG - complete the exhaustive version at M2 (doc-site / README pass), grounded in
397
+ as-built behavior. Accrue failure modes phase-by-phase as the build reports them; do NOT write the
398
+ full catalog speculatively now. Categories to cover, each as symptom → cause → fix (seller + buyer
399
+ wording):
400
+
401
+ - Verification: signature fails (raw-body transformed, wrong secret, expired ts, clock skew);
402
+ per-provider quirks (Stripe v1 rotation, Paddle destination secret, form-urlencoded providers).
403
+ - Mapping: product_id not in PRODUCT_TEAM_MAP → silent fall-through to defaults; price-id vs
404
+ product-id confusion; Stripe metadata.product_id unset.
405
+ - Username: malformed handle; well-formed but non-existent (404 → access.failed); wrong-but-real
406
+ handle (invite unaccepted); already-an-org-member edge case.
407
+ - Invitations: invite not accepted; invite expired; pending-invite cleanup; org membership vs team
408
+ membership.
409
+ - Claim flow: link expired (30d) / already used / lost; claim.pending never completed; re-issue.
410
+ - GitHub auth/permissions: PAT expired; fine-grained PAT not enabled at org; missing Members:R&W;
411
+ wrong GITHUB_ORG slug; team not attached to repo.
412
+ - Rate limits: 50/24h new-org cap, 500/24h, secondary limits, 422 - durable backoff behavior.
413
+ - Revoke: log_only vs auto_revoke; partial refund does NOT revoke; chargeback always revokes;
414
+ grant record missing → reconciliation fallback.
415
+ - Webhooks/infra: wrong secret path; duplicate delivery (idempotent); KV/Workflows binding
416
+ misconfig; secrets not set in prod (wrangler secret put); custom domain/routes.
417
+ - Outbound events: EVENT_WEBHOOK_URL unreachable/SSRF-blocked; signature on outbound; delivery
418
+ failure never blocks the grant.
419
+
420
+ Cross-link each entry to the relevant docs/providers/*.md and PRD section. This block also seeds the
421
+ support-agent RAG corpus.
422
+ -->