@homie-rocks/studio 0.33.1 → 0.34.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.
- package/CHANGELOG.md +41 -1
- package/README.md +6 -6
- package/lib/build.mjs +3 -3
- package/lib/cloudflare.mjs +3 -2
- package/lib/scaffold.mjs +37 -38
- package/lib/shop-catalog.mjs +4 -4
- package/lib/shop.mjs +13 -14
- package/lib/template-history.json +40 -0
- package/package.json +1 -1
- package/shop/SELLING.md +14 -92
- package/shop/SHOP.md +147 -274
- package/shop/shop.ts +20 -5
- package/worker/index.mjs +7 -1
- package/worker/office.mjs +1 -1
- package/worker/players.mjs +1 -1
- package/worker/referrals.mjs +9 -12
- package/worker/shop-page.mjs +91 -31
- package/worker/shop-rules.mjs +39 -58
- package/worker/shop-store.mjs +101 -9
- package/worker/shop.mjs +552 -212
- package/worker/stripe.mjs +21 -12
- package/worker/version.mjs +1 -1
package/shop/SHOP.md
CHANGED
|
@@ -1,292 +1,165 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Your shop, your choices
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
is the seller; homie.rocks never holds, routes or settles its money, is not a Stripe Connect platform, and takes
|
|
6
|
-
no cut. There is no shared currency between studios. `SELLING.md` beside this file is the owner's plain-words
|
|
7
|
-
note on what selling means for them (refunds, disputes, tax, kids; not legal advice).
|
|
3
|
+
You sell through your own Stripe account on your own Cloudflare account. Homie takes no cut.
|
|
4
|
+
The studio is the seller and is responsible for the law where it sells and for Stripe's terms.
|
|
8
5
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
At the studio's root, reviewed in git. Nothing secret is ever in it.
|
|
6
|
+
A minimal `shop.json`:
|
|
12
7
|
|
|
13
8
|
```json
|
|
14
9
|
{
|
|
15
|
-
"till": "stripe",
|
|
16
|
-
"currency": "usd",
|
|
17
|
-
"policy": { "preset": "protective" },
|
|
18
10
|
"items": [
|
|
19
|
-
{ "id": "
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
{ "id": "season-1", "kind": "pass", "name": "Season 1", "price": 800, "ends": "2027-01-31", "gives": ["track:season-1"] },
|
|
23
|
-
{ "id": "tip", "kind": "tip", "name": "Buy the studio a coffee", "price": "choose", "min": 200, "max": 5000 }
|
|
24
|
-
],
|
|
25
|
-
"referrals": { "rate": 0.10, "windowDays": 30, "capPerPlayer": 1000, "holdDays": 30, "minimumInvoice": 2500 }
|
|
11
|
+
{ "id": "badge", "name": "Studio badge", "price": 500, "gives": ["badge:studio"] },
|
|
12
|
+
{ "id": "gift", "kind": "tip", "name": "A gift", "price": "choose" }
|
|
13
|
+
]
|
|
26
14
|
}
|
|
27
15
|
```
|
|
28
16
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
17
|
+
The default is an open shop: guests can buy, repeatedly, from any page, without an age question.
|
|
18
|
+
Guest purchases belong to their browser session; signing in keeps them. Clearing the guest cookie loses
|
|
19
|
+
access, so adding a passkey is useful. Checkout checks browser cookies and explains how to enable them if blocked. Item kinds and wording are yours; nothing scans them.
|
|
20
|
+
A kind is a label, not a recurring-billing, wallet, random-reward or fulfillment implementation.
|
|
21
|
+
|
|
22
|
+
## Settings you can choose
|
|
23
|
+
|
|
24
|
+
- `till`: `stripe` (default), `stripe-managed`, or `off`; `currency`: `usd` by default.
|
|
25
|
+
`automaticTax: true` enables Stripe Tax. Managed Payments uses Stripe's eligibility and tax settings.
|
|
26
|
+
- Items: unique `id`, `name`, `price`, optional `kind` (default `item`), `blurb`, `gives`, `badge`,
|
|
27
|
+
`game` (catalog placement), `advantage`, `taxCode`, `starts`, `ends`, and `days`.
|
|
28
|
+
Any item can use `price: "choose"`; a `tip` uses it when price is omitted, or can have a fixed price.
|
|
29
|
+
Variable amounts have optional `min` (zero if omitted) and `max` (unbounded if omitted).
|
|
30
|
+
Dates and durations apply only when written.
|
|
31
|
+
- `capPerPlayerMonth`: optional spending cap. A cart reserves its whole pre-tax total atomically.
|
|
32
|
+
- `checkoutMinutes`: optional session lifetime, 30 to 1440 minutes (values below 31 use 31 to leave a transport margin); Stripe defaults to 1440 (24 hours).
|
|
33
|
+
Returning from checkout or starting another checkout asks Stripe to expire your open session.
|
|
34
|
+
Its reservation releases only when Stripe confirms expiry or another terminal outcome.
|
|
35
|
+
- `refundDays`: optional self-service refund window for items, including used items by default.
|
|
36
|
+
A tip is never refunded by the player. The studio can refund any paid order or line from its office; disputed charges wait for Stripe to resolve the dispute.
|
|
37
|
+
- `referrals`: optional `rate`, `windowDays`, `capPerPlayer`, `holdDays`, `minimumInvoice`,
|
|
38
|
+
`billingEmail` and `accept`. `rate` defaults to zero until written;
|
|
39
|
+
omitted limits do not apply. `referralNewPlayersOnly: true` limits attribution to new players.
|
|
40
|
+
- `catalog`: modes (`test`, `live`) populated by `homie-studio shop catalog` for named Stripe Products.
|
|
41
|
+
Prices always come from your file, even when a Stripe Product exists.
|
|
42
|
+
- Flood protection: `purchaseAttemptsPerMinute` (6 per buyer),
|
|
43
|
+
`purchaseAttemptsPerAddressPerMinute` (600), `guestBuyersPerAddressPerHour` (600).
|
|
44
|
+
These are configurable positive integers, per Worker instance. `PLAYER_LIMIT_DAILY` also applies.
|
|
45
|
+
Referral delivery uses `REFERRAL_STATEMENTS_PER_MINUTE`, `REFERRAL_STATEMENTS_GLOBAL_PER_MINUTE`
|
|
46
|
+
(both 30), and `REFERRAL_STATEMENT_BYTES` (65536). `requestBytes` overrides the catalog-sized shop request
|
|
47
|
+
allowance; `SHOP_WEBHOOK_BYTES` (524288) controls the webhook byte allowance.
|
|
48
|
+
|
|
49
|
+
No toolkit ceiling applies to prices, quantities, tips, durations, refund windows, caps or referral terms.
|
|
50
|
+
Money is integer Stripe currency units, with checked safe arithmetic. Stripe's currency rules and
|
|
51
|
+
[Checkout's maximum of 100 lines](https://docs.stripe.com/api/checkout/sessions/create#line_items) apply.
|
|
52
|
+
Free and paid lines can share a cart. An entirely free cart grants locally without a Stripe call. Product text limits and currency-unit requirements come from Stripe;
|
|
53
|
+
[Stripe decides settlement minimums and payment-method limits](https://docs.stripe.com/currencies).
|
|
54
|
+
|
|
55
|
+
## Optional policy bundles
|
|
56
|
+
|
|
57
|
+
An omitted `policy` or omitted `preset` uses the open settings (`custom`).
|
|
58
|
+
Write `"policy": { "preset": "protective" }` to choose the earlier account and age behavior.
|
|
59
|
+
Existing files with no preset now use the open default. Explicit settings remain in effect.
|
|
60
|
+
Each field can be overridden independently.
|
|
61
|
+
|
|
62
|
+
| Policy | Open / custom | protective | adults-only |
|
|
64
63
|
|---|---|---|---|
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `paidRandomRewards`, `countdownOffers`, `virtualCurrency` | false | false | true |
|
|
74
|
-
| `supporterAdvantages`, `repeatPurchases`, `refundUsedItems`, `televisionCheckout` | false | false | true |
|
|
75
|
-
|
|
76
|
-
`children` and `teens` each accept `deny`, `parent`, or `allow`. The neutral age question stores only a band,
|
|
77
|
-
never the birth year. `childAge` and `adultAge` set the band thresholds (13 and 18); the neutral picker spans 120 years. A parent's link lasts a week as a security expiry. Account-free purchases use a guest
|
|
78
|
-
session in this browser; clearing its cookie loses access, so buyers can add a passkey to keep their purchases.
|
|
79
|
-
The protective preset hides the shop on kids servers and kids studios, blocks purchases under 13, requires
|
|
80
|
-
parent checkout for teens, hides game advantages on beginner and kids servers, refuses paid randomness and
|
|
81
|
-
countdowns, and sends TV shoppers to a phone. Nothing relaxes those protections without a studio setting.
|
|
82
|
-
|
|
83
|
-
For example, `{ "policy": { "preset": "protective", "repeatPurchases": true } }` changes only repeat purchases.
|
|
84
|
-
The custom preset deliberately enables all policies; choose it only as a considered studio decision.
|
|
85
|
-
`kind` is descriptive, not a whitelist: services and subscription labels still use the existing one-time checkout.
|
|
86
|
-
Virtual-currency keys and random-reward metadata do not implement a wallet or random-reward fulfillment engine.
|
|
87
|
-
|
|
88
|
-
### Currency and abuse protection
|
|
64
|
+
| `requireAccount`, `ageQuestion` | false | true | true |
|
|
65
|
+
| `children` | allow | deny | deny |
|
|
66
|
+
| `teens` | allow | parent | deny |
|
|
67
|
+
| `kidsStudio`, `kidsServer` | true | false | false |
|
|
68
|
+
| `kidsAdvantages`, `beginnerAdvantages` | true | false | false |
|
|
69
|
+
| `repeatPurchases`, `refundUsedItems`, `televisionCheckout` | true | false | false |
|
|
70
|
+
| `withdrawalAcknowledgement` | false | true | true |
|
|
71
|
+
| `childAge`, `adultAge` | 13, 18 (inactive without age policy) | 13, 18 | 13, 18 |
|
|
89
72
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
Errors name Stripe as the source. See [Stripe currencies](https://docs.stripe.com/currencies).
|
|
94
|
-
Zero-price Checkout orders are supported and can be revoked without a Stripe refund. Every amount setting accepts only nonnegative whole minor units (numbers or decimal numeric strings); a fractional amount stops the build, names the setting and says what to write. No amount is rounded. All amounts must fit JavaScript safe integer arithmetic. Homie does not impose its own price ceiling or fee floor.
|
|
73
|
+
Age-band choices are `allow`, `deny`, or `parent`. The question retains a band, not a birth year.
|
|
74
|
+
Parent links expire after a week for security. The TV option can show a phone code instead of checkout.
|
|
75
|
+
Content scans do not exist in any preset.
|
|
95
76
|
|
|
96
|
-
|
|
97
|
-
address. Both accept positive whole numbers (including decimal numeric strings). The larger address rate
|
|
98
|
-
allows households, schools and venues to share a connection; rotating addresses still hits the account rate.
|
|
99
|
-
`guestBuyersPerAddressPerHour` defaults to 600 new guest buyers per address per hour, allowing a venue or school to arrive together. It must be a positive whole number; 0 cannot disable it accidentally. The existing `PLAYER_LIMIT_DAILY` also applies.
|
|
100
|
-
Checkout, parent links and parent payments have separate counts, per Worker instance. Referral statements use
|
|
101
|
-
`REFERRAL_STATEMENTS_PER_MINUTE` (30 per address), `REFERRAL_STATEMENTS_GLOBAL_PER_MINUTE` (30 globally), and
|
|
102
|
-
`REFERRAL_STATEMENT_BYTES` (65536). Written values must be positive whole decimal numbers; invalid values produce a named settings error. Security
|
|
103
|
-
checks for origins, sessions, signed webhooks and request sizes remain. They protect the studio's money.
|
|
104
|
-
|
|
105
|
-
## In a game
|
|
77
|
+
## Game API and carts
|
|
106
78
|
|
|
107
79
|
```ts
|
|
108
80
|
import { createShop } from '@homie-rocks/studio/shop';
|
|
109
81
|
const shop = createShop();
|
|
110
|
-
await shop.ready;
|
|
111
|
-
|
|
112
|
-
shop.on('change', (
|
|
113
|
-
|
|
114
|
-
shop.
|
|
82
|
+
await shop.ready;
|
|
83
|
+
shop.has('badge:studio');
|
|
84
|
+
shop.on('change', () => redraw());
|
|
85
|
+
shop.open(); // show the shop
|
|
86
|
+
shop.add('badge', 2); // quantity
|
|
87
|
+
shop.add('gift', 1, 500); // chosen tip amount in currency units
|
|
88
|
+
await shop.checkout(); // one Stripe Checkout Session
|
|
89
|
+
await shop.buy('badge'); // direct purchase stays one call
|
|
90
|
+
shop.cart(); shop.clear();
|
|
91
|
+
shop.used('badge:studio'); // used-item policy applies only if chosen
|
|
115
92
|
```
|
|
116
93
|
|
|
117
|
-
The
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
Stripe
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
1. `npx --no-install homie-studio shop init --supporter` (shop.json and SELLING.md), then `shop check`, commit,
|
|
191
|
-
`npm run deploy` (it applies migration `0008_studio_shop.sql`; the shop stays closed).
|
|
192
|
-
2. **The catalog**, through Stripe's MCP: `homie-studio shop catalog` names the one read to make
|
|
193
|
-
(`stripe_api_read GET /v1/products`, expanding each default price); `homie-studio shop catalog --have <the saved
|
|
194
|
-
answer>` lists exactly the `stripe_api_write` calls still needed: each item a Product with the id
|
|
195
|
-
`homie_<studio slug>_<item id>` (the same in a sandbox and live), its name, blurb, a tax code (video games,
|
|
196
|
-
downloaded: `txcd_10201000`, or `txcd_10201001` for an item that lasts days or ends on a date; eligible for
|
|
197
|
-
Managed Payments) and metadata naming the studio and the item, with a default Price of shop.json's amount (a tip:
|
|
198
|
-
the Product only). A changed price is a new Price made the default; an item gone from shop.json is archived, never
|
|
199
|
-
deleted; a product the studio did not make is never named. When Stripe matches, it says so and writes
|
|
200
|
-
`"catalog": ["test"]` into shop.json (commit, deploy). shop.json's checked price is always what is charged; the
|
|
201
|
-
checkout names the Product so Stripe's Dashboard, reports and MCP see sales by product, and a Product missing in
|
|
202
|
-
this mode falls back to the item described inline.
|
|
203
|
-
3. **Tax**, read through Stripe's MCP: `GET /v1/tax/settings` (with the studio as the seller, Stripe Tax needs
|
|
204
|
-
`status: active`, the business address, or checkouts fail) and `GET /v1/tax/registrations` (none: Stripe Tax
|
|
205
|
-
collects nothing anywhere until the owner registers somewhere). Reading only; the owner and their accountant decide.
|
|
206
|
-
4. `npx --no-install homie-studio shop connect`: a page on the owner's computer (127.0.0.1, one use, ten minutes). The
|
|
207
|
-
owner makes one restricted key with **Checkout Sessions: Write, Charges: Write (refunds), PaymentIntents: Read,
|
|
208
|
-
Disputes: Read, Webhook Endpoints: Write**, not an Agent key, and pastes it. With it the page reads once (a typo is
|
|
209
|
-
caught here), **makes the webhook** at `https://<site>/api/shop/hook` for `checkout.session.completed`,
|
|
210
|
-
`checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`,
|
|
211
|
-
`charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`, `charge.dispute.closed` (API version
|
|
212
|
-
`2025-03-31.basil`, metadata `homie: shop-v1`), and puts the key and the webhook's signing secret, which Stripe hands
|
|
213
|
-
back once, straight into the Worker secrets `STRIPE_KEY` and `STRIPE_WEBHOOK_SECRET` on Wrangler's standard input.
|
|
214
|
-
An older endpoint the kit made for the same address is turned off, never deleted. The page offers the seller
|
|
215
|
-
choice (the studio, or Stripe Managed Payments, with what each costs and does) and, in test mode with Managed
|
|
216
|
-
Payments, tries one test checkout with it (expired at once) and says whether Stripe took it. Afterwards the owner
|
|
217
|
-
may set the key's Webhook Endpoints back to None (recommended; the shop never needs it again). A key without
|
|
218
|
-
Webhook Endpoints: the page takes a webhook signing secret the owner made and revealed in Stripe instead. Test keys
|
|
219
|
-
only, unless `--live`.
|
|
220
|
-
5. `homie-studio shop` says whether anything is still missing. Buy your own item with a Stripe test card, and
|
|
221
|
-
refund it from `/_studio/office/shop`. Through Stripe's MCP, `GET /v1/webhook_endpoints` shows the endpoint the
|
|
222
|
-
page made (its `we_…` id): if it is not there, the MCP is signed in to another account or sandbox than the key.
|
|
223
|
-
6. **Live**, only when the owner says so: they activate the account, give Stripe's MCP access to the live account,
|
|
224
|
-
`shop catalog --mode live` makes the live catalog the same way, and `shop connect --live` takes a live key.
|
|
225
|
-
|
|
226
|
-
Never ask for a key in a chat, never put one in a file, an argument, a log or a commit. **Never make a webhook endpoint
|
|
227
|
-
or event destination through Stripe's MCP**: its answer carries the signing secret into the conversation (in Claude
|
|
228
|
-
Code the Homie mod refuses the call). Never use a Stripe CLI login or a key found on the computer.
|
|
229
|
-
|
|
230
|
-
## The office
|
|
231
|
-
|
|
232
|
-
`/_studio/office/shop` (the owner's browser) and `/_studio/api/shop` (an office key): open or what is missing,
|
|
233
|
-
the last 30 days, every order with its Stripe page, Refund (one tap), disputes, payouts, balance and tax as links
|
|
234
|
-
to the studio's own Stripe Dashboard, a CSV for the accountant, and the referral books. From an office key a
|
|
235
|
-
refund (`POST /_studio/api/shop/refund`, `homie-studio shop refund <order>`) is always an ASK the owner confirms
|
|
236
|
-
with one tap; so is marking a referrer paid.
|
|
237
|
-
|
|
238
|
-
A refund made anywhere else (in Stripe's Dashboard, or through Stripe's MCP after the owner approved Stripe's own
|
|
239
|
-
confirmation link) reaches the shop as `refund.created` / `charge.refunded`, and the item leaves the player's account
|
|
240
|
-
the same way. If the Worker's key is an Agent key, Stripe holds the Worker's refunds for a person's approval
|
|
241
|
-
(`approval_required`): the office answers 202 "held", nothing changes until someone approves it in Stripe (Settings,
|
|
242
|
-
Approvals), and the webhook finishes it. Reconnect with a plain restricted key to get the one tap back.
|
|
243
|
-
|
|
244
|
-
"How are sales?": `homie-studio shop` and `shop orders` (the studio's own books, no names), and through Stripe's MCP,
|
|
245
|
-
read-only, `stripe_analytics` or `stripe_api_read` on the balance, payouts and checkout sessions.
|
|
246
|
-
|
|
247
|
-
## Referral pages
|
|
248
|
-
|
|
249
|
-
`/api/shop?cursor=...` serves 100 items per page and returns `nextCursor`. The shop page and in-game sheet offer the next page.
|
|
250
|
-
The office's `/_studio/api/shop?cursor=...` pages by referrer, with exact SQL totals and sale counts shown once per referrer and currency.
|
|
251
|
-
`/_studio/api/shop/lines?via=...¤cy=...&cursor=...` pages the sales inside that book without repeating its totals.
|
|
252
|
-
Statement pages use `/_studio/api/shop/statements?period=YYYY-MM&cursor=...`; `shop statements --cursor ...` reads the next page.
|
|
253
|
-
One signed statement per referrer and currency has its totals on the first page only; continuation pages carry lines. A new first page replaces all previously received pages for that period and currency.
|
|
254
|
-
Read-only statement views read the current lines without making stored editions.
|
|
255
|
-
`shop statements --send` and the office's Send statements button save their progress and immutable lines in D1.
|
|
256
|
-
Keep the command or tab open to continue; close midway and start again to resume safely, without doubles or skipped
|
|
257
|
-
pages. After completion, starting again sends a fresh edition safely. Failed pages retry three times with exponential
|
|
258
|
-
backoff (at least a minute for rate refusals). Persistent failures name the referrer, stop its remaining pages and
|
|
259
|
-
continue the rest; starting a fresh run retries those referrers. The CLI reuses one office key, renewing its expiry
|
|
260
|
-
near ten minutes. Mark paid applies only to the selected currency and cannot change the edition being sent.
|
|
261
|
-
Received signed pages are available at `/_studio/api/shop/received?seller=...&period=...&cursor=...`. No row is hidden by a row cap.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
## Referrals
|
|
266
|
-
|
|
267
|
-
The studio sets `rate` (a nonnegative share, including values over 100%), `windowDays`, `capPerPlayer`,
|
|
268
|
-
`holdDays`, `minimumInvoice` and accepted invoice methods. No ceilings or forced relation between hold and
|
|
269
|
-
refund days. Omitted window and per-player cap impose neither; omitted hold means no hold and omitted minimum
|
|
270
|
-
invoice imposes no minimum. `rate` keeps the existing 10% suggestion when referrals are explicitly enabled.
|
|
271
|
-
An absent `referrals` pays nothing. Published invoice terms are settled by the studio, outside Homie.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
- A link with `?via=<host>` (another studio, homie.rocks, anywhere) opened by a person whose browser has never
|
|
275
|
-
been to this studio leaves a signed cookie with the host and the time (only on a studio that pays referrals;
|
|
276
|
-
never a prefetch, a crawler, this site, or a server's page or room).
|
|
277
|
-
- A paid order by an account made after that arrival, within `windowDays`, writes a referral line in THIS
|
|
278
|
-
studio's D1, if the referrer's own `/.well-known/homie-studio.json` says it takes statements. Held for
|
|
279
|
-
`holdDays`; a refund or a lost dispute inside the hold voids it, after it a clawback nets off the next statement.
|
|
280
|
-
- `homie-studio shop statements [--send]` signs each referrer's statement with the studio's Ed25519 key (the
|
|
281
|
-
public half is in the manifest's `referrals.key`) and POSTs it to the referrer's `/api/referrals/statement`,
|
|
282
|
-
which checks the signature against the seller's own manifest. No player, name or email is in a statement.
|
|
283
|
-
- Paying stays outside Homie: the referrer invoices the seller from its own Stripe; the seller pays and marks it
|
|
284
|
-
paid in the office.
|
|
285
|
-
|
|
286
|
-
## Testing without money
|
|
287
|
-
|
|
288
|
-
The kit's tests run the Worker against `stripe-mock` (Stripe's open-source API mock, which checks every parameter
|
|
289
|
-
against Stripe's own API description) and sign webhooks with a test secret. stripe-mock also takes every write
|
|
290
|
-
`shop catalog` lists, a checkout that names its Product, and the webhook the connect page makes. Stripe's MCP is
|
|
291
|
-
never called by the tests: `shop catalog --have` reads an answer in the shapes Stripe's API documents. `STRIPE_API_BASE` (a loopback address
|
|
292
|
-
only) points a Worker at such a mock; it is never honoured for anything but 127.0.0.1 or localhost.
|
|
94
|
+
The studio chooses where to place or open its shop. The shell verifies game messages and the Worker verifies
|
|
95
|
+
sessions, ownership, origins and payments. `GET /api/player/owns` includes quantity in each detail row.
|
|
96
|
+
The shop page has quantities, Add to cart, Remove and Checkout cart. The game API opens checkout in another tab, keeping the game and room running. If another tab cannot open, checkout uses the current tab and returns to the game or TV page after payment or cancellation. The cart stays in memory and is lost when its game page closes.
|
|
97
|
+
|
|
98
|
+
`POST /api/shop/buy` accepts `{ "lines": [{ "item": "badge", "quantity": 2 }] }` or
|
|
99
|
+
`{ "item": "badge" }`. Tip lines also take `amount`. Client-supplied fixed prices are ignored.
|
|
100
|
+
One order stores every line and its item snapshot. `/api/shop/mine` lists the lines;
|
|
101
|
+
`POST /api/shop/refund` takes `order` and optionally `line`. The office offers both.
|
|
102
|
+
|
|
103
|
+
## Connecting and operating
|
|
104
|
+
|
|
105
|
+
`homie-studio shop init --supporter` writes an example item and `SELLING.md`, without a policy, cap or refund
|
|
106
|
+
window. `shop check` validates settings; the build uses the same validation.
|
|
107
|
+
`shop connect` opens a local page for the studio's Stripe key and webhook secret; `--live` selects live mode.
|
|
108
|
+
Both restricted (`rk_`) and full secret (`sk_`) keys are accepted. A restricted key is useful: Checkout Sessions Write, Charges Write, PaymentIntents Read, Disputes Read;
|
|
109
|
+
Webhook Endpoints Write lets the connect page create the webhook. The key and webhook signing secret are both
|
|
110
|
+
part of working payment setup; without either, readiness names the missing step and opens no checkout.
|
|
111
|
+
[Stripe fulfillment](https://docs.stripe.com/checkout/fulfillment) uses webhooks as well as the redirect.
|
|
112
|
+
Credentials stay in Worker secrets. A preview normally has no database and cannot sell. A preview that you
|
|
113
|
+
configure with a database, key and signing secret sells into that database.
|
|
114
|
+
Stripe's agent tools can populate Products with `shop catalog` and read sales; secret-returning endpoint
|
|
115
|
+
creation belongs on the connect page. Stripe approval links are handled by the owner.
|
|
116
|
+
|
|
117
|
+
`shop`, `shop orders`, and `/_studio/office/shop` show readiness and sales. Office refunds are owner actions;
|
|
118
|
+
`shop refund <order>` asks the owner to confirm. Stripe controls pending or held refunds.
|
|
119
|
+
The office links Stripe's disputes and provides CSV exports. A dispute does not lock a player's account;
|
|
120
|
+
a lost dispute revokes the order's entitlements. Referral statements record what the studio owes;
|
|
121
|
+
the studio pays referrers directly and marks payment in its office.
|
|
122
|
+
|
|
123
|
+
## Payment guarantees and schema
|
|
124
|
+
|
|
125
|
+
The schema step `0012_shop_lines.sql` follows the reservation and statement steps. Deploy applies it through
|
|
126
|
+
the existing migration mechanism. It adds tables and columns without changing any existing primary key or
|
|
127
|
+
column meaning. Released code keeps working after the step, including through rollback. The new Worker
|
|
128
|
+
without the step still reads owned items and refunds from the office; new sales wait for the named step.
|
|
129
|
+
|
|
130
|
+
A verified payment atomically grants all line snapshots and records the referral share on the order total.
|
|
131
|
+
Duplicate events, reconciliation and late payments grant exactly once, including after a missing mark,
|
|
132
|
+
owner release, expiry or failure. Refunds revoke only their own lines, retain other purchases of the same key,
|
|
133
|
+
and adjust the referral share with exact integer allocation. Stripe tax is read per line when applicable.
|
|
134
|
+
Snapshots preserve delivery after catalog edits; the schema step represents older orders as one line.
|
|
135
|
+
An unpaid older order has no historical item snapshot and needs its catalog definition to complete delivery.
|
|
136
|
+
A missing definition gives a named retryable response and office note; the office can refund the verified
|
|
137
|
+
payment. Older paid orders missing a grant are repaired once. An unknown older item is refunded by the office,
|
|
138
|
+
since its original kind cannot be recovered safely for player self-service.
|
|
139
|
+
A whole-order refund uses one Stripe refund for the remaining charge. Dashboard partial refunds reduce cap
|
|
140
|
+
usage and referral shares by their pre-tax portion, without assigning money to an unnamed line.
|
|
141
|
+
Refunds are rebuilt from [Stripe’s refunds list](https://docs.stripe.com/api/refunds/list). Only
|
|
142
|
+
[succeeded refunds](https://docs.stripe.com/api/refunds/object#refund_object-status) revoke items;
|
|
143
|
+
pending refunds keep them and failed refunds restore them. A named line loses only its own items;
|
|
144
|
+
an untagged Dashboard refund revokes items only when the whole order is refunded.
|
|
145
|
+
Unneeded signed events are acknowledged without recording. A refund or dispute naming an unrecorded payment
|
|
146
|
+
of this database recovers its Checkout Session first, regardless of event age.
|
|
147
|
+
|
|
148
|
+
Sessions reserve the optional cap until Stripe confirms an outcome. With the default lifetime, a sessionless
|
|
149
|
+
reservation ages out after 24 hours plus a one-minute margin; your `checkoutMinutes` changes that window.
|
|
150
|
+
[Stripe session lifetime](https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-expires_at)
|
|
151
|
+
and [expiring open sessions](https://docs.stripe.com/api/checkout/sessions/expire) define these provider bounds.
|
|
152
|
+
Replacing an open checkout expires only reservations at least a minute old; a named cancellation can expire immediately.
|
|
153
|
+
Unresolved orders become eligible for reconciliation after one minute, even with no cap. A buyer's next shop
|
|
154
|
+
or owned-items request schedules up to three reads off the response path, with atomic claims and backoff up
|
|
155
|
+
to an hour; an optional Worker cron invokes the same reconciliation across buyers. No cron is installed for you. To run it every five minutes, add `"triggers": { "crons": ["*/5 * * * *"] }` to the studio’s root `wrangler.jsonc`; deploy preserves its triggers.
|
|
156
|
+
Every recorded payment checks Stripe’s refunds and dispute state, including payments recovered by an early refund or dispute event. The thanks page can verify a payment directly with the studio's key. Webhook events require Stripe's signature.
|
|
157
|
+
Test and live books stay separate. Owner release attempts to expire an open session, records who released it
|
|
158
|
+
and when, and never prevents a later verified payment grant.
|
|
159
|
+
|
|
160
|
+
Test mode uses Stripe's test cards. Test a cart, its grants and individual refunds before choosing live mode.
|
|
161
|
+
|
|
162
|
+
Game and TV checkout use the same tab if a new tab is unavailable, then return to the page the buyer left.
|
|
163
|
+
During rollback, the released Worker grants only a cart’s first item until the new Worker repairs it; it
|
|
164
|
+
allows whole-cart player refunds even when a tip is included, and counts partially refunded carts at
|
|
165
|
+
the full amount toward a cap. The additive schema preserves its original rows and entitlement keys.
|
package/shop/shop.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* await shop.ready;
|
|
8
8
|
* if (shop.has('skin:ember')) hull.material = ember; // what the player bought, by the key shop.json gives
|
|
9
9
|
* shop.on('change', () => redrawSkins()); // a purchase landed (or a refund took one back)
|
|
10
|
-
* button.onclick = () => shop.open('ember-skin'); // the store sheet
|
|
11
|
-
* shop.used('skin:ember'); // equipped:
|
|
10
|
+
* button.onclick = () => shop.open('ember-skin'); // the store sheet
|
|
11
|
+
* shop.used('skin:ember'); // equipped: the studio refund policy applies
|
|
12
12
|
*
|
|
13
13
|
* WHERE IT COMES FROM. The game runs in a sandboxed frame that can see no cookie and send no request as the site. The
|
|
14
14
|
* play shell around it (on the studio's own origin) asks the studio's Worker what the signed-in player owns and
|
|
@@ -28,9 +28,9 @@ export const SHOP_VERSION = 1;
|
|
|
28
28
|
export interface ShopState {
|
|
29
29
|
/** The studio sells something in this game, and this room may show it. */
|
|
30
30
|
open: boolean;
|
|
31
|
-
/**
|
|
31
|
+
/** The studio policy hides this shop on the current kids server. */
|
|
32
32
|
kids: boolean;
|
|
33
|
-
/**
|
|
33
|
+
/** The studio policy uses a phone code on this screen. */
|
|
34
34
|
screen: boolean;
|
|
35
35
|
}
|
|
36
36
|
export type ShopEvent = 'change' | 'closed';
|
|
@@ -42,6 +42,8 @@ export interface ShopOptions {
|
|
|
42
42
|
timeoutMs?: number;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export interface CartLine { item: string; quantity: number; amount?: number }
|
|
46
|
+
|
|
45
47
|
export interface Shop {
|
|
46
48
|
/** Resolves once the shell answered (or after `timeoutMs` with no shell: an empty, closed shop). */
|
|
47
49
|
readonly ready: Promise<ShopState>;
|
|
@@ -52,9 +54,15 @@ export interface Shop {
|
|
|
52
54
|
entitlements(): string[];
|
|
53
55
|
/** Ask the shell for a fresh answer (the shell also looks again by itself after a purchase). */
|
|
54
56
|
refresh(): Promise<string[]>;
|
|
55
|
-
/** Show the store sheet, at one item if given.
|
|
57
|
+
/** Show the store sheet, at one item if given. The studio chooses where to open it. */
|
|
56
58
|
open(item?: string): void;
|
|
57
59
|
close(): void;
|
|
60
|
+
/** Add an item and quantity to the local cart. Tip amounts use Stripe currency units. */
|
|
61
|
+
add(item: string, quantity?: number, amount?: number): void;
|
|
62
|
+
cart(): CartLine[];
|
|
63
|
+
clear(): void;
|
|
64
|
+
checkout(): Promise<boolean>;
|
|
65
|
+
buy(item: string, quantity?: number, amount?: number): Promise<boolean>;
|
|
58
66
|
/** Mark an item used; the studio's refundUsedItems policy decides whether self-service refunds still apply. */
|
|
59
67
|
used(key: string): Promise<boolean>;
|
|
60
68
|
on(ev: 'change', fn: (owns: string[]) => void): () => void;
|
|
@@ -92,6 +100,7 @@ export function createShop(opts: ShopOptions = {}): Shop {
|
|
|
92
100
|
if (target && g.addEventListener) {
|
|
93
101
|
g.addEventListener('message', (e: MessageEvent) => {
|
|
94
102
|
const m = e.data as Msg;
|
|
103
|
+
if (e.source !== target) return;
|
|
95
104
|
if (!m || typeof m !== 'object' || m.t !== 'homie-shop') return;
|
|
96
105
|
if (typeof m.q === 'number' && waiting.has(m.q)) { const r = waiting.get(m.q)!; waiting.delete(m.q); r(m); return; }
|
|
97
106
|
if (m.ev === 'owns') { if (setOwns(m.owns)) emit('change', owns.slice()); }
|
|
@@ -109,6 +118,7 @@ export function createShop(opts: ShopOptions = {}): Shop {
|
|
|
109
118
|
])
|
|
110
119
|
: Promise.resolve({ ...state });
|
|
111
120
|
|
|
121
|
+
let cart: CartLine[] = [];
|
|
112
122
|
const shop: Shop = {
|
|
113
123
|
ready,
|
|
114
124
|
get state() { return { ...state }; },
|
|
@@ -116,6 +126,11 @@ export function createShop(opts: ShopOptions = {}): Shop {
|
|
|
116
126
|
entitlements: () => owns.slice(),
|
|
117
127
|
refresh: async () => { const m = await send({ op: 'owns' }); if (m.ok && setOwns(m.owns)) emit('change', owns.slice()); return owns.slice(); },
|
|
118
128
|
open: (item) => { void send({ op: 'open', ...(typeof item === 'string' ? { item } : {}) }); },
|
|
129
|
+
add: (item, quantity = 1, amount) => { cart.push({ item, quantity, ...(amount === undefined ? {} : { amount }) }); },
|
|
130
|
+
cart: () => cart.map((line) => ({ ...line })),
|
|
131
|
+
clear: () => { cart = []; },
|
|
132
|
+
checkout: async () => { const ok = Boolean((await send({ op: 'checkout', lines: cart })).ok); if (ok) cart = []; return ok; },
|
|
133
|
+
buy: async (item, quantity = 1, amount) => Boolean((await send({ op: 'checkout', lines: [{ item, quantity, ...(amount === undefined ? {} : { amount }) }] })).ok),
|
|
119
134
|
close: () => { void send({ op: 'close' }); },
|
|
120
135
|
used: async (key) => (KEY.test(String(key)) && owns.includes(String(key)) ? Boolean((await send({ op: 'used', key })).ok) : false),
|
|
121
136
|
on: ((ev: ShopEvent, fn: (x: unknown) => void) => {
|
package/worker/index.mjs
CHANGED
|
@@ -118,7 +118,7 @@ import {
|
|
|
118
118
|
// every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
|
|
119
119
|
usePlayers(playerAccounts);
|
|
120
120
|
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
121
|
-
import { readiness, roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
|
|
121
|
+
import { reconcileOrders, readiness, roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
|
|
122
122
|
import { DISCOVERY_FILES, discoveryResponse, llmsTxt, robotsTxt, sitemapXml } from './discover.mjs';
|
|
123
123
|
import { ldScript, studioNode } from './schema.mjs';
|
|
124
124
|
import { arrivalCookie, manifestReferrals } from './referrals.mjs';
|
|
@@ -591,6 +591,12 @@ function finish(res, path) {
|
|
|
591
591
|
}
|
|
592
592
|
|
|
593
593
|
export default {
|
|
594
|
+
async scheduled(event, env, ctx) {
|
|
595
|
+
ctx.waitUntil((async () => {
|
|
596
|
+
const cat = await catalogue(env, 'https://studio.invalid');
|
|
597
|
+
await reconcileOrders(env, shopOf(cat));
|
|
598
|
+
})());
|
|
599
|
+
},
|
|
594
600
|
async fetch(request, env, ctx) {
|
|
595
601
|
const url = new URL(request.url);
|
|
596
602
|
// A standalone copy's Lobby call (worker/standalone.mjs): every answer to it can be read by the app's page, an
|
package/worker/office.mjs
CHANGED
|
@@ -1109,7 +1109,7 @@ export async function perform(env, cat, a) {
|
|
|
1109
1109
|
if (a.mode === 'off') notes.push('The guides are the game\'s plain bots.');
|
|
1110
1110
|
return { ok: true, op: a.op, game: meta.id, server: sv.id, brain: sv.brain, budget: day.budget, note: `Saved. ${notes.join(' ')}`.trim() };
|
|
1111
1111
|
}
|
|
1112
|
-
case 'shop-release': return performRelease(env, a);
|
|
1112
|
+
case 'shop-release': return performRelease(env, a, cat);
|
|
1113
1113
|
case 'refund': return performRefund(env, cat, a);
|
|
1114
1114
|
case 'shop-settle': return settleReferrer(env, a);
|
|
1115
1115
|
default: return { ok: false, error: 'op' };
|