@homie-rocks/studio 0.37.1 → 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.
- package/CHANGELOG.md +28 -0
- package/bin/homie-studio.mjs +4 -3
- package/lib/cloudflare.mjs +28 -3
- package/lib/dev.mjs +1 -1
- package/lib/parts-build.mjs +23 -4
- package/lib/parts-cli.mjs +54 -3
- package/lib/parts-purchase.mjs +174 -0
- package/lib/parts-store.mjs +50 -19
- package/lib/parts-tools.mjs +15 -12
- package/lib/parts-upload.mjs +43 -0
- package/lib/parts.mjs +49 -5
- package/lib/purchase-links.mjs +42 -0
- package/lib/purchase-wallet.mjs +114 -0
- package/lib/scaffold.mjs +25 -5
- package/lib/shoot.mjs +1 -1
- package/lib/shop-links.mjs +15 -6
- package/lib/shop.mjs +91 -21
- package/lib/stripe-connect.mjs +2 -2
- package/lib/template-history.json +41 -0
- package/package.json +12 -2
- package/parts/COMMERCIAL-LICENCE.md +21 -0
- package/parts/PARTS.md +238 -10
- package/worker/extensions.mjs +13 -0
- package/worker/index.mjs +3 -2
- package/worker/machine-payments.mjs +72 -0
- package/worker/office.mjs +4 -1
- package/worker/parts-protected.mjs +111 -0
- package/worker/parts-purchase-catalogue.mjs +34 -0
- package/worker/parts-resource.mjs +32 -0
- package/worker/parts-sale.mjs +59 -0
- package/worker/parts.mjs +24 -11
- package/worker/payment-capabilities.mjs +70 -0
- package/worker/payment-dry-exchange.mjs +30 -0
- package/worker/payment-manual-refund.mjs +40 -0
- package/worker/payment-policy.mjs +40 -0
- package/worker/payment-recovery.mjs +163 -0
- package/worker/payment-test-exchange.mjs +19 -0
- package/worker/purchase-core.mjs +140 -0
- package/worker/purchase-crypto.mjs +17 -0
- package/worker/purchase-delivery.mjs +108 -0
- package/worker/purchase-discovery.mjs +26 -0
- package/worker/purchase-hosted-readiness.mjs +16 -0
- package/worker/purchase-keys.mjs +20 -0
- package/worker/purchase-links.mjs +9 -0
- package/worker/purchase-machine.mjs +279 -0
- package/worker/purchase-mcp.mjs +42 -0
- package/worker/purchase-office-routes.mjs +17 -0
- package/worker/purchase-office-script.mjs +38 -0
- package/worker/purchase-office.mjs +35 -0
- package/worker/purchase-owner.mjs +243 -0
- package/worker/purchase-pricing.mjs +8 -0
- package/worker/purchase-problem.mjs +4 -0
- package/worker/purchase-reconciliation.mjs +582 -0
- package/worker/purchase-response.mjs +82 -0
- package/worker/purchase-routes.mjs +611 -0
- package/worker/purchase-schema.mjs +50 -0
- package/worker/purchase-store.mjs +148 -0
- package/worker/purchases.mjs +6 -0
- package/worker/resource-kinds.mjs +20 -0
- package/worker/selling/index.mjs +8 -0
- package/worker/selling/runtime.mjs +42 -0
- package/worker/settlement-journal.mjs +138 -0
- package/worker/shop-page.mjs +7 -4
- package/worker/shop-refunds.mjs +22 -0
- package/worker/shop-rules.mjs +1 -1
- package/worker/shop.mjs +18 -18
- package/worker/stripe.mjs +36 -3
- package/worker/version.mjs +1 -1
package/parts/PARTS.md
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
# Studio parts
|
|
2
2
|
|
|
3
|
-
Studios
|
|
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. **
|
|
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`.
|
|
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
|
-
| `
|
|
96
|
-
| `
|
|
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.
|
|
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
|
+
});
|