@homie-rocks/studio 0.37.0 → 0.37.2

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/homie-studio.mjs +10 -3
  3. package/lib/cloudflare.mjs +40 -6
  4. package/lib/dev.mjs +1 -1
  5. package/lib/doctor.mjs +7 -7
  6. package/lib/domain.mjs +43 -0
  7. package/lib/jobs.mjs +17 -7
  8. package/lib/mcp-tools.mjs +49 -18
  9. package/lib/mcp.mjs +6 -11
  10. package/lib/parts-build.mjs +23 -4
  11. package/lib/parts-cli.mjs +54 -3
  12. package/lib/parts-purchase.mjs +174 -0
  13. package/lib/parts-store.mjs +50 -19
  14. package/lib/parts-tools.mjs +15 -12
  15. package/lib/parts-upload.mjs +43 -0
  16. package/lib/parts.mjs +49 -5
  17. package/lib/prepare.mjs +47 -0
  18. package/lib/purchase-links.mjs +42 -0
  19. package/lib/purchase-wallet.mjs +114 -0
  20. package/lib/scaffold.mjs +35 -5
  21. package/lib/shoot.mjs +1 -1
  22. package/lib/shop-links.mjs +15 -6
  23. package/lib/shop.mjs +91 -21
  24. package/lib/stripe-connect.mjs +2 -2
  25. package/lib/template-history.json +82 -0
  26. package/package.json +12 -2
  27. package/parts/COMMERCIAL-LICENCE.md +21 -0
  28. package/parts/PARTS.md +238 -10
  29. package/worker/extensions.mjs +13 -0
  30. package/worker/index.mjs +3 -2
  31. package/worker/machine-payments.mjs +72 -0
  32. package/worker/office.mjs +4 -1
  33. package/worker/parts-protected.mjs +111 -0
  34. package/worker/parts-purchase-catalogue.mjs +34 -0
  35. package/worker/parts-resource.mjs +32 -0
  36. package/worker/parts-sale.mjs +59 -0
  37. package/worker/parts.mjs +24 -11
  38. package/worker/payment-capabilities.mjs +70 -0
  39. package/worker/payment-dry-exchange.mjs +30 -0
  40. package/worker/payment-manual-refund.mjs +40 -0
  41. package/worker/payment-policy.mjs +40 -0
  42. package/worker/payment-recovery.mjs +163 -0
  43. package/worker/payment-test-exchange.mjs +19 -0
  44. package/worker/purchase-core.mjs +140 -0
  45. package/worker/purchase-crypto.mjs +17 -0
  46. package/worker/purchase-delivery.mjs +108 -0
  47. package/worker/purchase-discovery.mjs +26 -0
  48. package/worker/purchase-hosted-readiness.mjs +16 -0
  49. package/worker/purchase-keys.mjs +20 -0
  50. package/worker/purchase-links.mjs +9 -0
  51. package/worker/purchase-machine.mjs +279 -0
  52. package/worker/purchase-mcp.mjs +42 -0
  53. package/worker/purchase-office-routes.mjs +17 -0
  54. package/worker/purchase-office-script.mjs +38 -0
  55. package/worker/purchase-office.mjs +35 -0
  56. package/worker/purchase-owner.mjs +243 -0
  57. package/worker/purchase-pricing.mjs +8 -0
  58. package/worker/purchase-problem.mjs +4 -0
  59. package/worker/purchase-reconciliation.mjs +582 -0
  60. package/worker/purchase-response.mjs +82 -0
  61. package/worker/purchase-routes.mjs +611 -0
  62. package/worker/purchase-schema.mjs +50 -0
  63. package/worker/purchase-store.mjs +148 -0
  64. package/worker/purchases.mjs +6 -0
  65. package/worker/resource-kinds.mjs +20 -0
  66. package/worker/selling/index.mjs +8 -0
  67. package/worker/selling/runtime.mjs +42 -0
  68. package/worker/settlement-journal.mjs +138 -0
  69. package/worker/shop-page.mjs +7 -4
  70. package/worker/shop-refunds.mjs +22 -0
  71. package/worker/shop-rules.mjs +1 -1
  72. package/worker/shop.mjs +18 -18
  73. package/worker/stripe.mjs +36 -3
  74. package/worker/version.mjs +1 -1
package/parts/PARTS.md CHANGED
@@ -1,14 +1,10 @@
1
1
  # Studio parts
2
2
 
3
- Studios build on each other by sharing **parts**: a reusable piece of a game, app, music or video that its studio chooses to share, so somebody
4
- making another game can use it. A creature from one game, a level from another, a bot brain from a third. That is
5
- the whole idea. Without parts, a creator's AI has to come up with everything on its own.
6
-
7
- Mashing up is not a command: it is making a game with parts from several games.
3
+ Studios share **parts**: reusable files another studio can find, license and install. A part may be a game mechanic, an app's quiz, menu board or waitlist screen, a music loop or complete track, or video. It can be free or priced. The licence says where it may be used, including a venue when the seller chooses that use.
8
4
 
9
5
  ## The rules
10
6
 
11
- 1. **A part is a reusable piece, never a whole game or app.** Remix, which handed over an entire game, is retired;
7
+ 1. **The lifting command extracts a piece, never a whole game or app.** Music, video and app assets can be authored directly in a part folder. Remix, which handed over an entire game, is retired;
12
8
  parts are what replaces it.
13
9
  2. **A part can take any form** that suits the piece: code, assets, data, a JSON description, a tuned config, or a
14
10
  mix. Form is not restricted.
@@ -89,11 +85,12 @@ named after it):
89
85
  | Field | What it is |
90
86
  | --- | --- |
91
87
  | `id`, `name`, `kind`, `version`, `summary` | `version` is three numbers; a shared version never changes, so a change is a new version. `summary` is one sentence. |
92
- | `kind` | `character`, `rig`, `clips`, `environment`, `effect`, `sound`, `ui`, `mechanic`, `shader`, `level-generator`, `bot-brain`, `audio-pack`, `set-piece`. `quiz`, `menu-board`, `waitlist`, `live-total`, `loop`, `stem`, `track`, `video-intro`, `video-template` also name parts. There is no kind for a whole game or app. |
88
+ | `kind` | `character`, `rig`, `clips`, `environment`, `effect`, `sound`, `ui`, `mechanic`, `shader`, `level-generator`, `bot-brain`, `audio-pack`, `set-piece`. These are suggestions, not a whitelist. `app`, `music` and `video` are also suggested; any lowercase label of letters, digits and hyphens is accepted. |
93
89
  | `license`, `attribution` | Needed to share: an SPDX identifier, and who a game using the part credits. |
94
90
  | `share` | **Default `false`.** The part's own switch, and the only thing that decides whether it is shared. |
95
- | `uses` | Optional intended uses: `game`, `app`, `venue`, `cause`, `music`, `video`. Describe the purpose, independently of licence and any future pricing. |
96
- | `from` | The source and studio: `game`, `app`, `music` or `video` with its id. Written when a part is lifted out of a game. |
91
+ | `from` | The source and studio: existing `{game, studio}` stays valid; non-game work may use `{kind, id, studio}`. Written when a part is lifted out of a game. |
92
+ | `uses` | Optional intended uses: `game`, `app`, `venue`, `cause`, `music`, `video`; the licence governs permission. |
93
+ | App/media provenance | The source and studio: `game`, `app`, `music` or `video` with its id. Written when a part is lifted out of a game. |
97
94
  | `entry` | For a part with code: the module a game imports. A part of assets or data has none. |
98
95
  | `files` | Every file with its SHA-256 and size, **written by the tool, never by hand**. A file may carry `"rights"` (below). |
99
96
  | `preview` | `page`: a self-contained HTML page shown in a sandboxed frame. `image`: a still. `aspect`: the frame's shape as `"W:H"` (whole numbers 1 to 32, no flatter than 3:1, no taller than 1:2; default `"16:9"`). `phoneAspect`: the shape when the frame is narrower than 600 px. |
@@ -169,6 +166,237 @@ build, whether or not the add named the game.
169
166
  alone.
170
167
  - The plumbing behind the tools is `homie-studio parts find | new | add | share | unshare | check`.
171
168
 
172
- App parts use the same lift/import/credit paths as game parts. Music and video parts can start empty with the corresponding kind; add their selected files, licence and provenance before sharing. `uses` describes what they are for, not permission to use them: the licence controls that. No price or checkout behavior is introduced; future commercial metadata can be added separately without changing the meaning of `share`.
169
+ App parts use the same lift/import/credit paths as game parts. Music and video parts can start empty with the corresponding kind; add their selected files, licence and provenance before sharing. `uses` describes what they are for, not permission to use them: the licence controls that. Optional `sale` metadata adds pricing without changing the meaning of `share`.
173
170
 
174
171
  For example: `homie-studio parts new welcome-queue --kind waitlist --uses app,venue`, or `parts new gala-loop --kind loop --uses venue,cause`. A source app is selected by the existing `--from <id>` and `--game <id>` options.
172
+
173
+ ## Selling a part to another studio
174
+
175
+ A part can be free or have `sale` in its `part.json`. The seller chooses its price,
176
+ licence and refund terms. Homie takes no cut and sets no price or spending ceiling
177
+ for studio purchases. Existing player shop rules apply to players, not these licences.
178
+
179
+ ```json
180
+ {
181
+ "license": "LicenseRef-Studio-Commercial",
182
+ "licenseTerms": "LICENSE.txt",
183
+ "sale": {
184
+ "amount": 2500,
185
+ "currency": "usd",
186
+ "billing": "one-time",
187
+ "scope": "studio",
188
+ "source": true,
189
+ "updates": "major",
190
+ "taxCode": "txcd_10000000",
191
+ "refund": "Contact the studio for the refund terms written in LICENSE.txt.",
192
+ "onRefund": "terminate",
193
+ "onExpiry": "retain",
194
+ "commercialUse": true,
195
+ "transferable": false,
196
+ "publicFiles": ["preview/demo.js"]
197
+ }
198
+ }
199
+ ```
200
+
201
+ This fragment is added to a complete part; choose the tax code for the actual goods.
202
+ `LICENSE.txt` must contain the complete seller-chosen licence and refund terms and
203
+ identify each custom licence. `license` also accepts SPDX expressions (`MIT OR
204
+ Apache-2.0`, for example); the toolkit never chooses an alternative for the buyer.
205
+ `LicenseRef-…` identifies a custom commercial licence, not an SPDX listed licence.
206
+ The structured summary and licence text must agree; the text is what the parties read.
207
+
208
+ - `amount` is a positive integer in Stripe's currency minor units (JPY has no cents).
209
+ There is no toolkit price ceiling; Stripe's own supported amounts still apply.
210
+ - `billing`: `one-time`, `day`, `week`, `month`, `year` (the recurring intervals Stripe supports). Recurring sales use Stripe Billing, with
211
+ paid-through periods from paid invoices. Cancellation uses Stripe's customer portal.
212
+ - `scope` is the seller’s label for licensed use (for example `studio`, `game`, `seat`, `venue` or `app`). A game licence names the buyer's game. A seat
213
+ licence has a quantity; seat assignment and transfer follow the written terms.
214
+ - `source`, `commercialUse`, `transferable` are explicit booleans. Package only the
215
+ files being sold. `source: false` does not strip or obfuscate files automatically.
216
+ - `updates`: `none`, `major`, `all`. Existing purchases use their original update
217
+ promise. Later list prices and new-buyer terms never remove the purchased promise. Included updates remain licensed under the purchased terms.
218
+ - `refund` is the seller's policy in plain words; `onRefund` and `onExpiry` choose
219
+ `retain` or `terminate` for rights to already downloaded copies. Full refunds and
220
+ lost disputes stop new downloads. Partial refunds leave the licence active.
221
+ - `publicFiles` explicitly names preview dependencies that may be downloaded without
222
+ buying. The preview page, image and licence text are public automatically. A browser
223
+ demo exposes whatever code and assets it uses. Keep private source out of previews.
224
+
225
+ On an already deployed studio, ask the AI to connect the shop and sell the part. The AI runs `shop init` if needed, `shop connect`, configures private storage and shares the release. **One browser approval** connects Stripe when its CLI session is not already authorized; an existing session needs **zero** new approvals. Account onboarding and Cloudflare authorization are separate provider requirements. Products, Prices, Payment Links and the webhook are created through Stripe's official CLI. The webhook signing secret and an automatically generated purchase signing key go privately into the Worker. No person finds, copies or pastes either secret.
226
+
227
+ The default hosted fallback uses these Payment Links with **no Stripe API key in the Worker**. The hosted review records the accepted terms, then redirects to Stripe with an opaque order reference. A verified signed event binds the exact link, revision, currency, amount and mode before granting files. Lost events wait for Stripe redelivery; the owner's AI can resend them through Stripe's CLI. Refund in Stripe or ask that connected AI. Keyless retirement returns the subscriptions for that AI to cancel through Stripe; signed cancellation events let retirement finish without revoking paid-period downloads. The shop's signed refund snapshots update access; the Worker makes no Stripe API request. The AI can preconfigure any positive quantities in `parts/offers.json`, for example `{"camera":{"quantities":[1,5,20]}}`, then run `shop connect`; it creates a fixed link for each chosen quantity. The default is one unit. A quantity without a matching link needs that setup sync or the fuller Session connection, so the paid quantity always matches the approved quote. Signed paid invoices supply recurring access periods; invoice-payment events associate their refunds. A later billing period never removes the rights the seller promised for earlier downloads.
228
+
229
+ Paid releases use a **separate private R2 bucket and `PURCHASE_MEDIA` binding**. Never enable a public R2 domain for it. Deploy verifies the selling Worker before uploading private files and reads every uploaded hash back. No paid payload is copied into public static assets; the normal Worker cannot read this bucket. Technical file and request limits are security/resource safeguards, not spending limits.
230
+
231
+ ### Which payment paths need credentials
232
+
233
+ | Path | Stripe API key in Worker? | Other requirements |
234
+ | --- | --- | --- |
235
+ | Hosted Payment Link fallback | **No** | CLI browser approval at setup; signed webhook; private signing key and storage |
236
+ | MPP Stripe shared payment tokens | **Yes** | Authenticated PaymentIntent creation, account profile and SPT eligibility |
237
+ | MPP over MCP | **Yes** | The same Stripe shared-token charge, carried by MCP |
238
+ | x402 Base recorded into Stripe | **Yes** | Authenticated transaction-verification PaymentIntent; live CDP facilitator credentials; Stripe-approved deposit address |
239
+ | Dynamic Checkout Sessions, direct refunds, provider-read recovery | **Yes** | Stripe API access for those operations |
240
+
241
+ Stripe's [MPP guide](https://docs.stripe.com/payments/machine/mpp) initializes the Stripe client with a secret credential. Its [x402 guide](https://docs.stripe.com/payments/machine/x402) separates the on-chain facilitator settlement from authenticated PaymentIntent recording. **x402 itself does not require a Stripe API key**; this supported path records money into the seller's Stripe books as Stripe documents. The keyless Payment Link path uses [signed webhooks](https://docs.stripe.com/webhooks) instead of provider reads. CLI OAuth is not exported as a Worker REST credential; see [the setup research](../../../docs/stripe-connect-research.md).
242
+
243
+ For a fuller connection, the AI operates Stripe's [key-creation page](https://docs.stripe.com/keys), selects the necessary permissions and clicks Copy, then runs `shop connect --from-clipboard` (`--live` for live mode). The local command privately reads the clipboard, verifies the key, makes the webhook and writes secrets to Wrangler stdin, then clears the clipboard. **The human only handles provider sign-in, verification or approval prompts**; their count is controlled by Stripe. Never ask the person to find or paste a key when browser control is available. No supported public API to mint a persistent Stripe REST credential from the CLI approval was established. On macOS this uses `pbpaste`/`pbcopy`, Windows its clipboard commands, and Linux Wayland `wl-paste`/`wl-copy`; other desktops require an equivalent private credential transport. The manual local form remains an explicit fallback, not the default instruction. Live CDP credentials likewise belong to the seller and should be transferred privately by the AI from the provider's setup; no Homie account is involved.
244
+
245
+ Turnstile is optional. If the studio configures it, the fallback verifies its hostname and action. The per-address Worker rate limiter remains configurable flood protection. Machine payment proofs are verified by the protocol libraries.
246
+
247
+ A release's bytes never change. `parts/offers.json` holds only price overrides and
248
+ availability choices. Other terms come from the release being offered. Each accepted
249
+ offer version stores the complete terms and price together. Earlier free copies cannot be recalled. `share: false` stops discovery
250
+ and new checkout after deploy; R2 keeps paid releases for existing buyers. Do not
251
+ remove purchased releases. Keep the packed files, D1 purchase records and signing key
252
+ in the seller's backups. Run shop connect again after adding paid parts to register the additional
253
+ subscription webhook events and permissions. Stripe's Dashboard must have its customer
254
+ portal enabled for subscription management; refunds and cancellation are separate.
255
+
256
+ ## Buying and keeping the proof
257
+
258
+ Requires studio **0.36.0**. Discovery uses `/.well-known/api-catalog`, linking
259
+ `/parts/catalog.json`, `/purchases/keys.json`, the HTTP resource and MCP endpoints.
260
+ The seller's public page and catalogue contain schema.org Product and Offer descriptions.
261
+
262
+ The buyer sees the full offer and exact quote before approving payment. Set
263
+ `HOMIE_PARTS_WALLET` to the person's installed `link-cli` or `purl` client.
264
+ The tool calls the wallet, which owns all spending authority and limits. Link card spends
265
+ currently require approval in the Link app. Retry the same purchase after that approval.
266
+ A compatible funded crypto wallet can use its existing authority. No seller checkout
267
+ page is involved in a successful protocol exchange.
268
+
269
+ MPP uses Stripe shared payment tokens. Tempo is disabled. x402 exact uses Base
270
+ payments to the studio's Stripe deposit address. Both return files, a payment receipt
271
+ and signed purchase proof. MCP uses MPP's JSON-RPC challenge and receipt binding.
272
+ Checkout remains the human fallback, and handles subscriptions, non-USD prices and final
273
+ tax calculation. Managed Payments is a Checkout option; machine charges use the studio
274
+ as seller. In fallback Checkout the person accepts the licence and presses Pay; an
275
+ assistant must not submit acceptance with a browser tool.
276
+
277
+ `shop connect` obtains the profile and stablecoin deposit addresses from Stripe and writes
278
+ `PURCHASE_MACHINE_PAYMENTS` and a mode/key-bound `PURCHASE_PAYMENT_CAPABILITIES` record. It needs
279
+ **PaymentIntents Write**, Business Profiles access, Payment Method Configurations reads and
280
+ Crypto Deposit Addresses access, as well as the existing Checkout and webhook permissions.
281
+ The fuller connection reports Stripe readiness errors. Connect and scheduled checks read the
282
+ profile, enabled methods and existing deposit addresses. They create no provider objects.
283
+ These reads establish configuration, not PaymentIntents Write permission, funded settlement
284
+ or SPT eligibility. Real purchase outcomes update readiness. The scheduled check retries
285
+ failures with backoff and rechecks successful configuration every six hours.
286
+ Stablecoins still require Stripe approval; the office reports unavailable methods and the
287
+ catalogue lists the available paths. Live Base additionally needs the studio's own
288
+ `CDP_API_KEY_ID` and `CDP_API_KEY_SECRET`. The toolkit does not invent those credentials.
289
+
290
+ At the documentation check on 2026-10-08, the Link wallet supports US and Canadian wallet accounts and a $500 spend-request
291
+ limit. An x402 client's default spend control can be $1 per payment; the wallet owner decides
292
+ whether to change it. These are provider/client constraints, not studio pricing limits.
293
+ `part_add` also accepts a `wallet` argument. A failed token clears pending wallet approval. Explicit wallet refusals, missing wallet software,
294
+ unsupported methods and unavailable machine paths offer a clearly labelled hosted Checkout fallback. An uncertain settlement must be reconciled before paying again.
295
+
296
+ `/openapi.json`, linked as `service-desc` from the API catalogue, publishes each offer's
297
+ request schema and `x-payment-info`. Buyers can submit the published `offerVersion`.
298
+ Challenges bind to the server hostname and an individual purchase resource URL. Unpaid
299
+ machine orders and challenges last five minutes; reprice, withdrawal and retirement close
300
+ old unpaid offers. The limiter applies before protected R2 file reads too.
301
+
302
+ Verified stablecoin settlements grant access even while Stripe recording is unavailable.
303
+ `purchase_jobs` retains the recording parameters and transaction reference. A five-minute
304
+ cron and on-demand order refresh retry recordings and re-read processing PaymentIntents.
305
+ If paid files cannot be restored, the owner must refund the order manually. Maintenance never
306
+ refunds for a storage outage. Every five minutes it reads unresolved Stripe objects and retries
307
+ delivery availability. The owner can run the same recovery with
308
+ `POST /_studio/api/shop/reconcile-payments` and an optional `order` identifier; buyers retrying
309
+ their purchase also trigger this read. Existing payment facts and order identifiers are retained
310
+ for late webhooks. Authentication, permission and idempotency errors are not charge refusals.
311
+ After 23 hours, no charge is replayed. Reads continue: a paid object is recorded; a canceled or
312
+ unpaid object releases the claim. An exhausted complete list after the deadline also releases
313
+ the claim while keeping its order. If Stripe remains unreadable, the office shows the order,
314
+ request key, recorded PaymentIntent (if known), age and last error. Reconnect the key and use
315
+ Reconcile payments now. If you have the exact PaymentIntent id, use Read this payment beside
316
+ the recovery row (or pass `payment` and `order` to the same endpoint). Its order metadata must
317
+ match. Use the normal refund action once Stripe confirms payment; an unpaid canceled object
318
+ releases the claim. Never infer no charge from an API error.
319
+
320
+ An explicit sandbox readiness check is available through
321
+ `POST /_studio/api/shop/readiness`. It reads current configuration without creating any provider object, order, entitlement or refund. Failures expire after five minutes;
322
+ success expires after six hours. The report records the check time and its limited scope.
323
+ Scheduled capability checks are read-only, at most four successful checks per day, with
324
+ backoff after failure. No extra permissions or checks apply to a shop without paid releases.
325
+
326
+ Paid objects live under `paid-parts/` in the private `PURCHASE_MEDIA` bucket, separate from
327
+ public `MEDIA`. Deployment creates that bucket, deploys the guarded Worker, then uploads and
328
+ verifies the files. Failure before deployment uploads nothing. An older public Worker has no
329
+ binding to those objects. Public routes also refuse the prefix if it is bound accidentally.
330
+
331
+ The studio can tune `PURCHASE_RATE_LIMITER` in its Wrangler configuration; deployment keeps
332
+ that binding’s chosen limit and period. It is an abuse control, not a spending allowance.
333
+
334
+ Stripe can initiate Managed Payments refunds within 60 days in certain cases, including
335
+ support escalations left unanswered for 48 hours, and can accept disputes. A customer’s
336
+ data-deletion request cancels their Managed Payments subscriptions. These are Stripe’s
337
+ rules, described in its [Managed Payments guide](https://docs.stripe.com/payments/managed-payments/how-it-works).
338
+ Provider reconciliation observes refunds and cancellation regardless of who requested them.
339
+
340
+ ## Seller settings and recovery
341
+
342
+ `part.json.sale` supplies terms for each immutable release. `parts offer <id> --amount`
343
+ sets only a persistent price override. Other terms change with a new release. Offers are
344
+ versioned from all terms, resource and price; a buyer keeps the version they purchased.
345
+ The build does not write `parts/offers.json`. Only the newest release sells by default;
346
+ `--release <version> --keep-on-sale` retains an older release. Release settings merge,
347
+ so adding an included upgrade does not silently withdraw an older release.
348
+
349
+ `--withdraw` closes new sales; `--on-sale` explicitly reopens an offer. Deployment expires
350
+ obsolete open Checkout sessions. A completed payment is honored even if its webhook
351
+ arrives after withdrawal or another release. `parts retire` closes sales and cancels
352
+ future subscription billing, preserving already-paid periods. `--upgrade included` may
353
+ add rights; changing it back cannot revoke promised or already-granted releases.
354
+
355
+ `intentMinutes` is 30–1440 and controls the approval intent before Checkout. Stripe chooses
356
+ the Checkout Session's default expiration, keeping retry parameters stable. Download
357
+ credentials last five minutes. Reconciliation happens once per grant rather than per file.
358
+ The studio can configure its abuse rate limit; it is not a spending cap.
359
+
360
+ `refundWindowDays` enables buyer self-service refunds when present and positive. It counts
361
+ from the current payment's paid timestamp, including renewals. Otherwise ask the seller.
362
+ `refundEndsSubscription` cancels future billing only after a successful full refund.
363
+ Pending or failed refunds keep billing intact. Credit-only invoices have no charge to refund.
364
+ `immediateDelivery: true` adds the consumer immediate-delivery acknowledgement beside Pay;
365
+ the seller chooses it to match its full licence and obligations.
366
+
367
+ A public test-card payment gets no protected files until the owner identifies the order
368
+ and runs `parts test-access <order>`. This authorizes that claim only while the shop remains
369
+ in test mode. Reissue needs new approval. Never approve an unknown test buyer. Use a second
370
+ studio for tests: one Worker has one Stripe key/mode, and switching a live shop to test
371
+ interrupts its live buyers. A grant or proof from test never becomes a live purchase.
372
+
373
+ Claims, download tokens and pristine backups live in `.homie/paid-parts`, outside public
374
+ source. Back them up privately. A long-lived Ed25519 proof cannot download; a short-lived
375
+ grant can. Updates derive from the purchased licence. A verified backup restores offline;
376
+ new delivery and updates require the seller to remain reachable. There is no project escrow.
377
+
378
+ If a claim is lost, `parts recover <seller>/<part> --order <order>` generates a new private
379
+ claim and public claim hash. Give the hash and Stripe receipt to the seller. The seller
380
+ matches the payer/receipt in Stripe and runs `parts reissue <order> --claim-hash <hash>
381
+ --receipt-verified`. Never pass `--receipt-verified` on the assistant's own word. Reissue
382
+ revokes the former claim and downloads. An order ID alone proves nothing. A healthy local
383
+ claim cannot be overwritten by recovery. Transfer requires the purchased terms to allow it.
384
+
385
+ `parts manage` creates a fresh Stripe portal session. Without a local record, a seller's
386
+ configured `purchasesPortalLogin` lets Stripe authenticate the buyer and provide billing and
387
+ receipt access for assisted recovery. Run refunds only when requested by the person.
388
+
389
+ Paid resource setup uses the `shop connect` page for `TURNSTILE_SITE_KEY` and
390
+ `TURNSTILE_SECRET` (hosted fallback), and `CDP_API_KEY_ID` and `CDP_API_KEY_SECRET`
391
+ (live Base). Machine and fallback readiness are independent. Base is an explicit choice
392
+ with manual refund responsibility; Tempo is disabled. If Stripe cannot record a settled
393
+ Base transfer, the office names the payer, token, atomic amount and transaction. Send any
394
+ owed refund from the studio's own wallet and use `shop refund <order> --manual-transaction
395
+ <hash>` to verify and record it after owner confirmation. Missing storage never authorizes
396
+ an automatic refund. Configure the studio's own Base RPC in purchase settings for recovery.
397
+
398
+ The recovery status `fulfilled` means the purchased files are available and the entitlement
399
+ has been issued; it is not proof that the buyer downloaded them. An unpaid challenge retains
400
+ a five-minute quote row so a retry uses the same terms and the owner can grant test access.
401
+ It creates no provider object. Cleanup removes that row only if no provider or settlement job
402
+ was ever attached. A paid order whose files cannot be restored needs an owner's manual refund.
@@ -0,0 +1,13 @@
1
+ /** Optional selling integration. Only the selling entry installs provider adapters. */
2
+ export const selling = {
3
+ enabled: false,
4
+ machineCapabilities: () => ({ machine: {} }),
5
+ priceWords: () => 'Free',
6
+ purchaseRoutes: () => null,
7
+ purchasePaymentEvent: () => null,
8
+ resourceOrder: () => null,
9
+ listOffers: () => [],
10
+ paidFile: () => new Response('not found', { status: 404 }),
11
+ jobsSQL: () => '0',
12
+ };
13
+ export function useSelling(adapters) { Object.assign(selling, adapters); }
package/worker/index.mjs CHANGED
@@ -346,6 +346,7 @@ function etagMatches(header, etag) {
346
346
  * object stored without one.
347
347
  */
348
348
  async function mediaObject(request, env, key, { cache = 'public, max-age=3600', type = null } = {}) {
349
+ if (key.startsWith('paid-parts/')) return new Response('not found', { status: 404 });
349
350
  const range = request.headers.get('range');
350
351
  const inm = request.headers.get('if-none-match');
351
352
  const headOnly = request.method === 'HEAD';
@@ -399,7 +400,7 @@ async function heldInR2(env, getCat, path) {
399
400
  try { want = decodeURIComponent(path); } catch { return null; }
400
401
  for (const e of [...(cat.songs ?? []), ...(cat.videos ?? [])]) {
401
402
  for (const f of e.files ?? []) {
402
- if (typeof f.r2 !== 'string' || !f.r2 || f.r2.startsWith('players/') || typeof f.url !== 'string') continue;
403
+ if (typeof f.r2 !== 'string' || !f.r2 || (f.r2.startsWith('players/') || f.r2.startsWith('paid-parts/')) || typeof f.url !== 'string') continue;
403
404
  let at;
404
405
  try { at = decodeURIComponent(f.url); } catch { continue; }
405
406
  if (at === want) return f;
@@ -749,7 +750,7 @@ async function route(request, env, ctx) {
749
750
  if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
750
751
  const key = decodeURIComponent(path.slice('/media/'.length));
751
752
  // A player's large saves live under players/ in the same bucket: never served here (saves/SAVES.md).
752
- if (!key || key.includes('..') || key.startsWith('players/')) return new Response('not found', { status: 404 });
753
+ if (!key || key.includes('..') || (key.startsWith('players/') || key.startsWith('paid-parts/'))) return new Response('not found', { status: 404 });
753
754
  return mediaObject(request, env, key);
754
755
  }
755
756
  if (path === '/posts/feed.xml' || path === '/posts/feed.json') {
@@ -0,0 +1,72 @@
1
+ import { journalFacilitator, rpcFor } from './settlement-journal.mjs';
2
+ import { machineCapabilities } from './payment-capabilities.mjs';
3
+ /** Standards adapters: the libraries own challenges, credentials and settlement. */
4
+ import Stripe from "stripe";
5
+ import { Mppx, stripe, Transport } from "mppx/server";
6
+ import { HTTPFacilitatorClient } from "@x402/core/server";
7
+ import { createFacilitatorConfig } from "@coinbase/x402";
8
+ import { paymentStore } from "./purchase-core.mjs";
9
+ import { digest } from "./purchase-crypto.mjs";
10
+
11
+ import { modeOf, apiBase } from "./stripe.mjs";
12
+
13
+ export function stripeClient(env) {
14
+ const base = new URL(apiBase(env));
15
+ return new Stripe(env.STRIPE_KEY, {
16
+ httpClient: Stripe.createFetchHttpClient(),
17
+ host: base.hostname,
18
+ port: base.port ? Number(base.port) : undefined,
19
+ protocol: base.protocol.slice(0, -1),
20
+ });
21
+ }
22
+
23
+ export async function paymentProtocol(
24
+ env,
25
+ order,
26
+ { mcp = false, client, facilitator, realm, capabilities, chainRpc } = {},
27
+ ) {
28
+ const mode = modeOf(env.STRIPE_KEY);
29
+ const config = JSON.parse(env.PURCHASE_MACHINE_PAYMENTS ?? "{}")[mode];
30
+ if (!config?.profile)
31
+ throw new Error("Configure the studio Stripe profile for machine payments");
32
+ client ??= stripeClient(env);
33
+ const payments = stripe.create({
34
+ client,
35
+ networkId: config.profile,
36
+ livemode: mode === "live",
37
+ metadata: { homie: "purchase-v1", order: order.id },
38
+ store: paymentStore(env.DB, mode),
39
+ });
40
+ capabilities ??= await machineCapabilities(env);
41
+ let methods = capabilities.machine.card ? [payments.spt.charge()] : [];
42
+ if (config.base && capabilities.machine.base && !mcp) {
43
+ facilitator ??= new HTTPFacilitatorClient(
44
+ mode === "live"
45
+ ? createFacilitatorConfig(env.CDP_API_KEY_ID, env.CDP_API_KEY_SECRET)
46
+ : { url: "https://x402.org/facilitator" },
47
+ );
48
+ methods = [
49
+ ...methods,
50
+ payments.base.charge({
51
+ recipient: config.base,
52
+ x402: { facilitator: journalFacilitator(env, order, facilitator, chainRpc ?? rpcFor(env, 'base', mode)), routeBinding: "resource" },
53
+ }),
54
+ ];
55
+ }
56
+ methods = methods.map((method) => {
57
+ // mppx's two-phase methods call broadcast after validate; card methods use
58
+ // verify. Capture the bound challenge before either terminal operation.
59
+ const operation = method.broadcast ? 'broadcast' : 'verify';
60
+ return { ...method, async [operation](context) {
61
+ order.challengeId = context.credential.challenge.id;
62
+ return method[operation](context);
63
+ } };
64
+ });
65
+ const protocol = Mppx.create({
66
+ methods,
67
+ realm,
68
+ secretKey: await digest(`${env.STRIPE_KEY}:purchase-payment-challenges`),
69
+ ...(mcp ? { transport: Transport.mcpSdk() } : {}),
70
+ });
71
+ return protocol;
72
+ }
package/worker/office.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { selling } from './extensions.mjs';
2
+ import { purchaseOrderIfPresent } from './shop.mjs';
1
3
  /**
2
4
  * THE STUDIO'S BACK OFFICE (@homie-rocks/studio 0.13.0): the studio's owner, and the owner's AI, see and run the
3
5
  * studio's live games. Everything lives in the studio's own Worker and D1; homie.rocks stores none of it.
@@ -1255,6 +1257,7 @@ async function api(request, env, url, cat) {
1255
1257
  return lounge ? json({ ok: true, lounge }) : json({ ok: false, error: 'no-lounge', message: 'This studio has no Lounge: add "lounge": true to studio.json and deploy.' }, 404);
1256
1258
  }
1257
1259
  // The shop (0.24.0): the owner's view, the accountant's CSV, the referral statements.
1260
+ if (selling.enabled) { const result = await selling.officeRoutes(request, env, path, body); if (result) return result; }
1258
1261
  if (path === '/_studio/api/shop' && request.method === 'GET') return json(await shopOffice(env, cat, url.origin, url.searchParams.get('cursor') ?? '', /^\d+$/.test(url.searchParams.get('itemCursor') ?? '') ? Number(url.searchParams.get('itemCursor')) : 0, /^\d+$/.test(url.searchParams.get('orderCursor') ?? '') ? Math.min(Number(url.searchParams.get('orderCursor')), Number.MAX_SAFE_INTEGER) : 0));
1259
1262
  if (path === '/_studio/api/shop/orders.csv' && request.method === 'GET') {
1260
1263
  try {
@@ -1283,7 +1286,7 @@ async function api(request, env, url, cat) {
1283
1286
  if (op === 'server-create' || op === 'pass' || op === 'lounge-night') action.origin = url.origin;
1284
1287
  if (op === 'refund') {
1285
1288
  // The ask and the confirm page say what the order is (its item and price), read from the books now.
1286
- const o = await orderById(env, action.order);
1289
+ const o = await orderById(env, action.order) ?? await purchaseOrderIfPresent(env, action.order);
1287
1290
  if (!o) return json({ ok: false, error: 'order', message: 'No such order (homie-studio shop orders lists them).' }, 404);
1288
1291
  Object.assign(action, { item: o.item, amount: Number(o.amount), currency: o.currency });
1289
1292
  }
@@ -0,0 +1,111 @@
1
+ import { hasStripeKey, shopMode } from './shop-links.mjs';
2
+ /** Studio licences use the shop's Stripe, order book, webhook and refund office. */
3
+ import {
4
+ orderState,
5
+ recordOrderState,
6
+ entitlement,
7
+ } from "./purchase-core.mjs";
8
+
9
+ import { signingKey, publicKeys } from "./purchase-keys.mjs";
10
+
11
+ import { GRANT_AUDIENCE, PROOF_AUDIENCE, manifestHash, signGrant, verifyGrant } from "./purchase-crypto.mjs";
12
+ import { legalTerms, objectKey, publicFiles } from "./parts-sale.mjs";
13
+
14
+ import {
15
+ json,
16
+ fail,
17
+ byId,
18
+ bearer,
19
+ purchasedResource,
20
+ active,
21
+ privateRelease,
22
+ coveredPurchase,
23
+ deliveryAllowed,
24
+ } from "./purchase-store.mjs";
25
+ /** Protected content is never in ASSETS; withdrawn releases remain reachable by their buyer. */
26
+ export async function paidFile(...args) {
27
+ try {
28
+ return await servePaidFile(...args);
29
+ } catch {
30
+ return fail(
31
+ 503,
32
+ "Purchase service temporarily unavailable. Retry with the same download credential.",
33
+ );
34
+ }
35
+ }
36
+ async function servePaidFile(
37
+ request,
38
+ env,
39
+ url,
40
+ id,
41
+ version,
42
+ path,
43
+ type = "application/octet-stream",
44
+ ) {
45
+ if (!env.PURCHASE_MEDIA) return null;
46
+ if (!env.PURCHASE_RATE_LIMITER) return fail(503, 'Deploy the purchase rate limiter');
47
+ if (!(await env.PURCHASE_RATE_LIMITER.limit({ key: `files:${request.headers.get('cf-connecting-ip') ?? 'unknown'}` })).success) return fail(429, 'Too many file requests; retry shortly');
48
+ const p = await privateRelease(env, 'part', id, version);
49
+ if (!p?.sale) return null;
50
+ const f = p.files.find((f) => f.path === path);
51
+ if (path !== "part.json" && !f) return fail(404, "No such file");
52
+ if (path === "part.json" || publicFiles(p).has(path)) {
53
+ if (path === "part.json") return json(p);
54
+ const obj = await env.PURCHASE_MEDIA.get(objectKey(f.sha256));
55
+ return obj
56
+ ? new Response(request.method === "HEAD" ? null : obj.body, {
57
+ headers: {
58
+ "content-type": type,
59
+ "access-control-allow-origin": "*",
60
+ "content-security-policy":
61
+ "sandbox allow-scripts allow-pointer-lock",
62
+ "cache-control": "public, max-age=31536000, immutable",
63
+ "x-content-type-options": "nosniff",
64
+ },
65
+ })
66
+ : fail(503, "File missing from storage");
67
+ }
68
+ let claims;
69
+ try {
70
+ for (const key of await publicKeys(env)) {
71
+ try {
72
+ claims = await verifyGrant(bearer(request), key, {
73
+ issuer: url.origin,
74
+ resource: id,
75
+ kind: 'part',
76
+ version,
77
+ });
78
+ break;
79
+ } catch {
80
+ /* retained verification key */
81
+ }
82
+ }
83
+ } catch {
84
+ return fail(503, "Seller signing keys unavailable");
85
+ }
86
+ if (!claims) return fail(402, "Buy this part before downloading its files");
87
+ const o = await byId(env, claims.jti);
88
+ const core = o && (await orderState(env, o.id));
89
+ if (
90
+ !o || o.resource_kind !== 'part' ||
91
+ !(await deliveryAllowed(env, o)) ||
92
+ !active(o) ||
93
+ (core && !["paid", "fulfilled", "disputed"].includes(core.state)) ||
94
+ o.buyer !== claims.sub ||
95
+ o.claim_hash !== claims.claimVersion ||
96
+ o.mode !== claims.mode ||
97
+ !(await coveredPurchase(env, url.origin, o, p)) ||
98
+ claims.manifest !== (await manifestHash(p))
99
+ )
100
+ return fail(403, "This purchase does not permit this download");
101
+ const obj = await env.PURCHASE_MEDIA.get(objectKey(f.sha256));
102
+ if (!obj) return fail(503, "File missing from storage");
103
+ return new Response(request.method === "HEAD" ? null : obj.body, {
104
+ headers: {
105
+ "content-type": "application/octet-stream",
106
+ "cache-control": "no-store, private",
107
+ "x-content-type-options": "nosniff",
108
+ "content-security-policy": "sandbox",
109
+ },
110
+ });
111
+ }
@@ -0,0 +1,34 @@
1
+ import { selling } from './extensions.mjs';
2
+ import { partsIndex } from './parts.mjs';
3
+ const CORS = { 'access-control-allow-origin': '*', 'x-content-type-options': 'nosniff' };
4
+ export function productDescription(p, origin) {
5
+ const sale = p.sale;
6
+ const digits = sale ? new Intl.NumberFormat('en', { style: 'currency', currency: sale.currency }).resolvedOptions().maximumFractionDigits : 2;
7
+ return { '@context': 'https://schema.org', '@type': 'Product', name: p.name, description: p.summary, url: `${origin}/parts/${p.id}/`, ...(p.preview?.image ? { image: new URL(`${p.url}${p.preview.image}`, origin).href } : {}), ...(sale ? { offers: { '@type': 'Offer', price: (sale.amount / 10 ** digits).toFixed(digits), priceCurrency: sale.currency.toUpperCase(), url: `${origin}/parts/${p.id}/`, availability: p.releases?.some((r) => r.onSale) ? 'https://schema.org/InStock' : 'https://schema.org/Discontinued', ...(sale.billing !== 'one-time' ? { priceSpecification: { '@type': 'UnitPriceSpecification', price: (sale.amount / 10 ** digits).toFixed(digits), priceCurrency: sale.currency.toUpperCase(), billingDuration: ({day:'P1D',week:'P1W',month:'P1M',year:'P1Y'})[sale.billing], unitText: sale.scope } } : {}), description: `${sale.billing}; per ${sale.scope}; ${sale.refund}` } } : { offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD', availability: 'https://schema.org/InStock', url: `${origin}/parts/${p.id}/` } }) };
8
+ }
9
+
10
+ export function entryDescription(p,cat,url,capabilities) { return {
11
+ product: productDescription(p, url.origin),
12
+ purchase: { methods: (cat.shop?.purchasesTill ?? cat.shop?.till) !== 'stripe-managed' && p.sale?.amount > 0 && p.sale?.billing === 'one-time' && p.sale?.currency === 'usd' && !p.sale?.automaticTax && p.sale?.taxBehavior !== 'exclusive' ? Object.entries(capabilities.machine).filter(([, enabled]) => enabled).map(([method]) => method) : [], hostedCheckout: { url: `${url.origin}/api/purchases/intent`, label: 'Hosted Checkout fallback' }, http: `${url.origin}/api/purchases/resource`, mcp: `${url.origin}/api/purchases/mcp`, keys: `${url.origin}/purchases/keys.json` },
13
+ }; }
14
+ export async function discoveryRoutes(request, env, url, { catalogueOf }) {
15
+ const path=url.pathname;
16
+ if (path === '/purchases/keys.json') return selling.purchaseRoutes(request, env, url, {});
17
+ if (path === '/openapi.json') {
18
+
19
+ return selling.purchaseDiscovery(env, url.origin, await catalogueOf());
20
+ }
21
+ const read = request.method === 'GET' || request.method === 'HEAD';
22
+ if (path === '/.well-known/api-catalog') {
23
+ if (!read) return new Response(null, { status: 405, headers: { allow: 'GET, HEAD' } });
24
+ const headers = { 'content-type': 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"', link: `<${url.origin}/.well-known/api-catalog>; rel="api-catalog"`, ...CORS };
25
+ const index = await partsIndex(env, url.origin);
26
+ const item = [{ href: `${url.origin}/parts/catalog.json`, type: 'application/json', title: 'Resources' }];
27
+ if (index.parts.some((p) => p.releases?.some((r) => r.onSale))) {
28
+ item.push({ href: `${url.origin}/api/purchases/mcp`, title: 'Purchases over MCP' });
29
+ if (env.PURCHASE_SIGNING_KEYS) item.push({ href: `${url.origin}/purchases/keys.json`, type: 'application/jwk-set+json', title: 'Purchase verification keys' });
30
+ }
31
+ return new Response(request.method === 'HEAD' ? null : JSON.stringify({ linkset: [{ anchor: `${url.origin}/.well-known/api-catalog`, 'service-desc': [{ href: `${url.origin}/openapi.json`, type: 'application/vnd.oai.openapi+json' }], item }] }), { headers });
32
+ }
33
+ return null;
34
+ }
@@ -0,0 +1,32 @@
1
+ /** A published part release is one purchasable resource kind. */
2
+ import { quoteHash, legalTerms, coversRelease, compareVersions, saleProblems, objectKey, releaseKey } from "./parts-sale.mjs";
3
+ export const partResource = Object.freeze({
4
+ async list(env, origin) {
5
+ const response = await env.ASSETS.fetch(new Request(`${origin}/parts/index.json`));
6
+ if (!response.ok) return [];
7
+ return ((await response.json()).parts ?? []).flatMap((p) => (p.releases ?? []).filter((r) => r.onSale && r.offer).map((r) => ({ id:p.id, name:p.name, version:r.version, offer:r.offer, offerVersion:r.offerVersion })));
8
+ },
9
+ async get(env, id, version) {
10
+ const object = await env.PURCHASE_MEDIA?.get(releaseKey(id,version));
11
+ return object ? object.json() : null;
12
+ },
13
+ quote: quoteHash,
14
+ terms: legalTerms,
15
+ validate(resource, selection) {
16
+ const errors = saleProblems(resource);
17
+ if (!selection) return errors;
18
+ const { quantity, game } = selection;
19
+ if (!Number.isSafeInteger(quantity) || quantity < 1 || !Number.isSafeInteger(quantity * resource.sale.amount)) errors.push('Invalid quantity');
20
+ if ((resource.sale.scope === 'game' && !/^[a-z0-9][a-z0-9-]{0,47}$/.test(game ?? '')) || (resource.sale.scope !== 'game' && game != null)) errors.push('Invalid game scope');
21
+ return errors;
22
+ },
23
+ termsUrl: (resource) => `/parts/${encodeURIComponent(resource.id)}/${encodeURIComponent(resource.version)}/${resource.licenseTerms.split('/').map(encodeURIComponent).join('/')}`,
24
+ readFile: (env,file,{head=false}={}) => head && env.PURCHASE_MEDIA?.head ? env.PURCHASE_MEDIA.head(objectKey(file.sha256)) : env.PURCHASE_MEDIA?.get(objectKey(file.sha256)),
25
+ async covers(env,origin,bought,wanted) {
26
+ if (coversRelease(bought,wanted)) return true;
27
+ const response = await env.ASSETS.fetch(new Request(`${origin}/parts/index.json`));
28
+ if (!response.ok) return false;
29
+ const release = (await response.json()).parts?.find((p)=>p.id===wanted.id)?.releases?.find((r)=>r.version===wanted.version);
30
+ return compareVersions(wanted.version,bought.version)>=0 && release?.upgrade==='included';
31
+ },
32
+ });