Skip to content

Commit 2cbcb8f

Browse files
authored
find the buyer by key or label, and refuse a tie (#10)
Stripe's API requires a key on every checkout custom field; Stripe's dashboard never asks for one and never shows one, and it will not add a field to a payment link that already exists. So the setup pages asked a seller to type a key they cannot set, on a link they cannot edit. Their field read to fulfillment as no field at all: paid orders skipped, a green run, and a buyer with nothing. Stores this engine built are unaffected, because init.js sets the key over the API. Fulfillment now ranks three signals and never pools them: the exact key, the key ignoring case and punctuation, then the label. Where several fields tie, the shape of the value breaks it; where even that cannot, nothing is invited and both keys are named, because an invitation to a private repo has no undo. The needs-attention row now carries which of the three reasons applied. Docs give the API call that adds the field to a link in use, with the URL unchanged, and say plainly that the dashboard route only exists for a new link, which gets a new URL. Co-authored-by: Lucide <279298543+LucideLarp@users.noreply.github.com>
1 parent 9ac899e commit 2cbcb8f

14 files changed

Lines changed: 485 additions & 61 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ buyer ──▶ storefront (GitHub Pages, static)
8686
──▶ appends your (private) sales ledger
8787
```
8888

89-
The fulfillment path is two dependency-free Node files: a 397-line driver on a
90-
473-line pure logic core (which is why it can be unit-tested without a
89+
The fulfillment path is two dependency-free Node files: a 403-line driver on a
90+
593-line pure logic core (which is why it can be unit-tested without a
9191
network). Read both: [`scripts/fulfill.js`](scripts/fulfill.js)
9292
and [`scripts/lib/fulfill-core.js`](scripts/lib/fulfill-core.js).
9393

docs/bring-your-own-store.md

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,72 @@ and there is still no reason to publish your product mapping.
5858
## The one change to your checkout
5959

6060
Every module here ties a payment to a buyer through one fact: the GitHub
61-
username asked for at checkout. If your link does not ask for it yet, add it
62-
once, in the Stripe dashboard, under Payment Links, your link, Edit, Add custom
63-
field:
61+
username asked for at checkout. If your links already collect it, you are done,
62+
and the rest of this section is reference.
63+
64+
What to add is a **required text field**, labelled
65+
66+
> **GitHub username (for repo access)**
67+
68+
Copy that label. Custom fields also carry a `key`, which is what an integration
69+
would normally match on, and on a link built in the dashboard the key is not
70+
yours: Stripe's API requires every custom field to have one, and the dashboard
71+
neither asks for a key nor shows you the one it stores. So the modules identify
72+
the field three ways, in order. The key `github_username` where you set it over
73+
the API. The same key through differences of case and punctuation. Otherwise the
74+
label, which is the only thing a dashboard-made field gives you to go on.
75+
76+
Keep to one such field per link. Where two could each be the username, an order
77+
is resolved by the shape of what the buyer typed if that separates them, and
78+
stopped with both field keys named if it does not, because an invitation to a
79+
private repo cannot be taken back and a wrong one is worse than a late one.
80+
81+
### Adding it to a link you already sell through
82+
83+
**The dashboard cannot do this.** On a payment link that already exists, Edit is
84+
unavailable, so there is no clicking route to add a field to it. One API call
85+
does, and **the link URL is unchanged**, so nothing you have published goes
86+
stale and no buyer sees a difference:
87+
88+
```bash
89+
curl https://api.stripe.com/v1/payment_links/plink_XXXXXXXXXXXXXXXXXXXXXX \
90+
-u "$STRIPE_SECRET_KEY:" \
91+
-d "custom_fields[0][key]=github_username" \
92+
-d "custom_fields[0][type]=text" \
93+
-d "custom_fields[0][label][type]=custom" \
94+
-d "custom_fields[0][label][custom]=GitHub username (for repo access)"
95+
```
96+
97+
Send every field the link should end up with, not only the new one: read what is
98+
already there with `GET /v1/payment_links/<id>` first.
99+
100+
That call is a write, and it is the only write anywhere in this document. It
101+
needs a key with write access to Payment Links, which is not one of the scopes
102+
in the table further down, because no module here ever writes to Stripe. Do it
103+
once, then go back to the read-only key the tools run on.
104+
105+
### Or start a new link
64106

65-
- label: **GitHub username (for repo access)**
66-
- key: `github_username`
67-
- type: text, required
107+
Then the dashboard route works: Options, Add custom fields, Text, type the
108+
label, mark it required. It will not ask you for a key. What a new link does
109+
give you is a **new URL**, so anything already pointing at the old one has to be
110+
updated, which is the reason the API call above is the better move for a link
111+
already in use.
68112

69-
The key is the load-bearing part. `github_username` is the exact string every
70-
module looks for (`reconcile/lib/reconcile-core.js`, `ops-bots/bots.js`), so a
71-
field with a different key reads as no field at all.
113+
### Knowing it worked, before a buyer tests it for you
114+
115+
The doctor reads your links and says, per link, whether anything here could find
116+
a buyer on them and what it matched on:
117+
118+
```
119+
[ OK ] stripe: plink_XXXXXXXXXXXXXXXXXXXXXX collects a GitHub username (matched on its label)
120+
```
72121

73-
That is the whole migration. Two minutes, and **the link URL does not change**,
74-
so nothing you have already published goes stale and no buyer sees a difference.
122+
A link that collects nothing it can read is a `[FAIL]` with the fix attached,
123+
not a silent pass, and the audit makes the same judgement standing, across every
124+
active link in the account rather than only the ones your config names. That
125+
pairing is deliberate: a link nobody can deliver against is the one setup
126+
mistake that stays invisible until a stranger has already paid.
75127

76128
### The orders you took before the field existed
77129

@@ -221,11 +273,11 @@ config /tmp/acme-ops/store.config.json
221273
shipped defect: real bug, reproduced, no victim (2026-07-20)
222274
no force-push guard that misses the refspec form
223275
224-
Live checks skipped (--static-only): payment-link coverage, forked-checkout
225-
detection, dead buy buttons, advertised-price drift, coupon exposure and
226-
invitation expiry all need read credentials. An entry above marked OK was
227-
judged on its static half only, and an entry with no live-checkable half
228-
is not listed at all.
276+
Live checks skipped (--static-only): payment-link coverage, unreadable
277+
checkouts, forked-checkout detection, dead buy buttons, advertised-price
278+
drift, coupon exposure and invitation expiry all need read credentials. An
279+
entry above marked OK was judged on its static half only, and an entry with
280+
no live-checkable half is not listed at all.
229281
230282
10 entries checked: 0 exposed · 0 warn · 3 unchecked · 7 guarded
231283

docs/failure-catalogue.md

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ This file is generated from `audit/lib/catalogue.js` (the same data the
2525
checks run against) by `node audit/audit.js --catalogue`. It is meant to be
2626
worth reading even if you never run the tool.
2727

28-
**13 entries.** 5 are incidents that happened on this store's own
29-
infrastructure. 4 are real defects we shipped and caught by execution before
28+
**14 entries.** 5 are incidents that happened on this store's own
29+
infrastructure. 5 are real defects we shipped and caught by execution before
3030
a buyer hit them. 4 have never happened here and are labelled as such: they are
3131
guarded because the first occurrence would be unrecoverable and invisible.
3232

@@ -72,8 +72,11 @@ GitHub expires an unaccepted repository invitation after 7 days. That is
7272
documented behaviour, not a secret: GitHub's changelog of 5 February 2020,
7373
"Self-expiring repository and organization invitations", says invitations to
7474
become a collaborator on a repository expire seven days after they are created.
75-
The REST API states no period of its own, so the engine also watches GitHub's
76-
`expired` flag rather than only the calendar. The non-obvious part is architectural:
75+
It is the only place GitHub says it. The REST API carries no period at all, so
76+
this check treats seven days as an assumption: it acts on GitHub's own `expired`
77+
flag, measures against a shorter window if an invitation on your store has
78+
actually died sooner, and says which of the two it used rather than presenting a
79+
deadline. The non-obvious part is architectural:
7780
NOTHING IN THE PIPELINE EVER REVISITS AN INVITATION IT SENT.
7881

7982
The engine invites the buyer and writes the ledger row in the same breath.
@@ -116,6 +119,46 @@ CI.
116119
**Checked by:** `grant-shape` (static)
117120
**Not checked:** None. Pure config shape, no credentials, runs on every push.
118121

122+
## A checkout that collects nothing you can deliver against
123+
124+
`unreadable-checkout` · shipped defect: real bug, reproduced, no victim · 2026-07-24
125+
· evidence: the exact-key lookup these modules shared (reconcile/lib/reconcile-core.js, ops-bots/bots.js), and the setup pages that asked a seller to type a key the dashboard does not offer (honorbox docs/setup.md, docs/bring-your-own-store.md)
126+
· cost: none here. Our own links are made over the API, which is the only route that can set a key at all
127+
128+
Every module in this suite answers one question first: which GitHub account
129+
does this payment belong to? It reads that off a custom field on the
130+
checkout. Get that wrong and nothing downstream can be right, because there
131+
is no other thread from a payment to a person.
132+
133+
The suite used to find that field by one exact key, `github_username`, and
134+
the setup instructions told you to type that key in when you added the field.
135+
Two facts make that instruction unfollowable. Stripe's API requires a unique
136+
`key` on every custom field, so a key always exists on the object. Stripe's
137+
DASHBOARD never asks for one and never shows one: the Add custom fields panel
138+
offers a field type, one "Label name" input and a few checkboxes, and no key
139+
appears afterwards on the field either. A seller who built their link by
140+
clicking cannot set the key, cannot read it, and cannot be told to use ours.
141+
142+
Their field is therefore a field, on a working checkout, that every tool
143+
reads as no field at all. The buyer types their username. The money lands.
144+
The order is skipped, the run is green, and nobody is invited. It is the same
145+
silent shape as a payment link with no fulfillment row, arrived at by
146+
following the setup guide correctly.
147+
148+
The fix has two halves and both are here. The modules now identify the field
149+
by its key OR by its label, and where more than one field could be it, they
150+
resolve by the shape of what the buyer typed and REFUSE where even that
151+
cannot separate them, because inviting a stranger to a private repo has no
152+
undo. This check is the other half: it reads your live links and tells you
153+
which ones nothing could deliver against, before a buyer finds out for you.
154+
155+
Worth saying plainly: this did not cost us anything, because our own links
156+
are made over the API and carry the key explicitly. It would have cost the
157+
next person who set one up by hand, which is exactly who the suite is for.
158+
159+
**Checked by:** `unreadable-checkout` (live)
160+
**Not checked:** Needs a Stripe key to read your links. It judges the field DEFINITION, so it cannot tell you whether buyers are filling it in sensibly, only whether anything here could find it.
161+
119162
## A forked store still selling through the original author's checkout
120163

121164
`foreign-checkout` · shipped defect: real bug, reproduced, no victim · 2026-07-19 / 2026-07-20
@@ -234,7 +277,7 @@ so buyers stop being delivered to. And the symptom is not an error, it is a
234277
cron that quietly stopped. Nothing in your store reports it. You find out
235278
from a customer.
236279

237-
Fixed by sizing the cadence to the tier (17,47 = 1,488 min/month, 512 minutes
280+
Fixed by sizing the cadence to the tier (*/30 = 1,488 min/month, 512 minutes
238281
of headroom) rather than by rewording the pricing claim.
239282

240283
Now the other half, because a catalogue that only frightens people is as

docs/least-privilege.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"You want my Stripe secret key in a GitHub Action?" No. HonorBox never
44
needs your full secret key, and it never needs a broad GitHub token.
55
Delivering a sale is two calls, both visible in
6-
[`scripts/fulfill.js`](../scripts/fulfill.js) (397 lines, read it):
6+
[`scripts/fulfill.js`](../scripts/fulfill.js) (403 lines, read it):
77

88
| Call | Why | Secret |
99
|---|---|---|

docs/pro-evidence.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -103,19 +103,19 @@ config /tmp/widget-store/store.config.json
103103
shipped defect: real bug, reproduced, no victim (2026-07-20)
104104
no force-push guard that misses the refspec form
105105
106-
Live checks skipped (--static-only): payment-link coverage, forked-checkout
107-
detection, dead buy buttons, advertised-price drift, coupon exposure and
108-
invitation expiry all need read credentials. An entry above marked OK was
109-
judged on its static half only, and an entry with no live-checkable half
110-
is not listed at all.
106+
Live checks skipped (--static-only): payment-link coverage, unreadable
107+
checkouts, forked-checkout detection, dead buy buttons, advertised-price
108+
drift, coupon exposure and invitation expiry all need read credentials. An
109+
entry above marked OK was judged on its static half only, and an entry with
110+
no live-checkable half is not listed at all.
111111
112112
10 entries checked: 4 exposed · 0 warn · 0 unchecked · 6 guarded
113113
The 4 exposed hold 5 separate findings, each listed above with its fix.
114114
115115
Each EXPOSED line above is a specific way this store loses a sale without
116116
telling you. They are listed with the fix; there is no score to improve.
117117
118-
13 catalogue entries · 5 from incidents here, 4 from defects we shipped and caught, 4 guarded before they happened.
118+
14 catalogue entries · 5 from incidents here, 5 from defects we shipped and caught, 4 guarded before they happened.
119119
Known gaps we have NOT closed (1). Read audit/CATALOGUE.md:
120120
- A 200 that acknowledges dispatch, not delivery: A short secondary throttle is retried in-run (30s per wait, 60s per run, 3 attempts); anything longer, including a primary rate limit, is declined by design and falls to the scheduled poll, whose real recovery time is the next run that actually fires, measured here at up to 3h08m on a quiet private repo.
121121
```
@@ -187,15 +187,15 @@ config /tmp/widget-store/store.config.json
187187
shipped defect: real bug, reproduced, no victim (2026-07-20)
188188
no force-push guard that misses the refspec form
189189
190-
Live checks skipped (--static-only): payment-link coverage, forked-checkout
191-
detection, dead buy buttons, advertised-price drift, coupon exposure and
192-
invitation expiry all need read credentials. An entry above marked OK was
193-
judged on its static half only, and an entry with no live-checkable half
194-
is not listed at all.
190+
Live checks skipped (--static-only): payment-link coverage, unreadable
191+
checkouts, forked-checkout detection, dead buy buttons, advertised-price
192+
drift, coupon exposure and invitation expiry all need read credentials. An
193+
entry above marked OK was judged on its static half only, and an entry with
194+
no live-checkable half is not listed at all.
195195
196196
10 entries checked: 0 exposed · 0 warn · 0 unchecked · 10 guarded
197197
198-
13 catalogue entries · 5 from incidents here, 4 from defects we shipped and caught, 4 guarded before they happened.
198+
14 catalogue entries · 5 from incidents here, 5 from defects we shipped and caught, 4 guarded before they happened.
199199
Known gaps we have NOT closed (1). Read audit/CATALOGUE.md:
200200
- A 200 that acknowledges dispatch, not delivery: A short secondary throttle is retried in-run (30s per wait, 60s per run, 3 attempts); anything longer, including a primary rate limit, is declined by design and falls to the scheduled poll, whose real recovery time is the next run that actually fires, measured here at up to 3h08m on a quiet private repo.
201201
```

docs/setup.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,39 @@ correctly-configured Payment Link, and wires `store.config.json` +
5454
[subscription enforcement](subscriptions.md). Delivery works either way, but
5555
without enforcement a cancellation does nothing and access stays forever.
5656
2. Create a **Payment Link** for that price:
57-
- Add a **custom field**: label "GitHub username (for delivery)",
58-
key `github_username`, type text, **required**.
57+
- Add a **custom field**, type text, **required**, labelled exactly
58+
**GitHub username (for repo access)**. Copy the label, because on a link
59+
made in the dashboard the label is the only part you control: Stripe's API
60+
requires every custom field to carry a `key`, and the dashboard neither
61+
asks for one nor shows you the one it stores. Fulfillment handles both
62+
cases. It matches the key `github_username` where the field was created
63+
over the API (which is what `init.js` does), it matches that key through
64+
differences of case and punctuation, and where the key is not one it
65+
recognises it identifies the field by that label. Keep to one such field
66+
per link: where two could each be the username, an order is resolved by
67+
the shape of what the buyer typed if that separates them, and stopped for
68+
a human if it does not, because an invitation to a private repo cannot be
69+
taken back.
70+
71+
**Adding this field to a link that already exists is not a dashboard
72+
job.** Once a link is created, Edit is unavailable on it, so there is no
73+
dashboard route to add a field. One API call does it, and the link URL is
74+
unchanged, so nothing you have already published goes stale:
75+
76+
```bash
77+
curl https://api.stripe.com/v1/payment_links/plink_YOURLINKID \
78+
-u "$STRIPE_SECRET_KEY:" \
79+
-d "custom_fields[0][key]=github_username" \
80+
-d "custom_fields[0][type]=text" \
81+
-d "custom_fields[0][label][type]=custom" \
82+
-d "custom_fields[0][label][custom]=GitHub username (for repo access)"
83+
```
84+
85+
Send every field the link should end up with, not only the new one: read
86+
what is already there with `GET /v1/payment_links/<id>` first. This is a
87+
write, so it needs a key with write access to Payment Links. Fulfillment
88+
itself never writes to Stripe, so go back to the read scopes in
89+
[least-privilege.md](least-privilege.md) once the field exists.
5990
- After payment → show a confirmation message like: *"You're in. Your GitHub
6091
account will be invited to the private repo, usually within minutes and
6192
always within a few hours. Watch for the email from GitHub and accept the

pages/deliver-digital-products-github.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ something must read it and send the invite:
5353
MIT-licensed [HonorBox](https://github.com/Honorboxx/honorbox) engine
5454
does)*. A GitHub Action lists recent paid sessions and invites each
5555
buyer. No server, no webhook secret, delivery in minutes. The engine is
56-
870 dependency-free lines you can read before trusting: a 397-line driver on
57-
a 473-line pure core.
56+
996 dependency-free lines you can read before trusting: a 403-line driver on
57+
a 593-line pure core.
5858
3. **By hand.** Works for the first sales; does not survive a weekend away.
5959

6060
Whichever you pick: make fulfillment **idempotent** (track processed session

pages/sell-code-without-a-marketplace.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ carrying the buyer's username. Something has to read it and send the invite:
8484
3. **A scheduled poll from CI** *(the section where we sell you something:
8585
this is what [HonorBox](https://github.com/Honorboxx/honorbox) does)*. A
8686
GitHub Action runs every few minutes, lists recent paid sessions, and
87-
invites each buyer. The engine is MIT, 870 dependency-free lines of
87+
invites each buyer. The engine is MIT, 996 dependency-free lines of
8888
Node you can read before trusting it. It also builds the storefront: a static
8989
site on GitHub Pages with your products, checkout buttons, and this same
9090
guide layout. Delivery lands in minutes rather than seconds; an opt-in

products/honorbox-pro.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,10 @@ buyer to a private repo, with glue you wrote once and have not opened since.
8484
That is the setup this was built for, and you do not have to adopt the engine to
8585
put Pro on it. Write a minimal config naming the payment links and product repos
8686
you already have, and add one custom field to your payment link asking for the
87-
buyer's GitHub username: a two-minute edit in the Stripe dashboard, and the link
88-
URL does not change, so nothing you have already published goes stale and no
89-
buyer sees a difference. Reconcile, stats, the refund and dispute guards, the
87+
buyer's GitHub username. On a link you already sell through that is one API
88+
call, not a dashboard edit, because Stripe offers no way to edit a payment link
89+
once it exists. It takes a minute, and **the link URL does not change**, so
90+
nothing you have already published goes stale and no buyer sees a difference. Reconcile, stats, the refund and dispute guards, the
9091
store doctor and the suite's money-path checks then run in full against your
9192
setup as it stands.
9293

0 commit comments

Comments
 (0)