@homie-rocks/studio 0.50.3 → 0.50.5

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 (47) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -2
  3. package/bin/homie-studio.mjs +8 -6
  4. package/functions/FUNCTIONS.md +125 -2
  5. package/guides/app/SKILL.md +14 -1
  6. package/guides/office/SKILL.md +14 -1
  7. package/guides/servers/SKILL.md +14 -1
  8. package/guides/shop/SKILL.md +4 -3
  9. package/lib/build.mjs +2 -3
  10. package/lib/events-cli.mjs +37 -0
  11. package/lib/functions-build.mjs +3 -1
  12. package/lib/mcp-tools.mjs +11 -0
  13. package/lib/parts-build.mjs +1 -1
  14. package/lib/parts.mjs +1 -1
  15. package/lib/purchase-links.mjs +1 -1
  16. package/lib/scaffold.mjs +11 -11
  17. package/lib/shop.mjs +2 -30
  18. package/lib/template-history.json +88 -1
  19. package/package.json +1 -1
  20. package/shop/SELLING.md +1 -1
  21. package/shop/SHOP.md +10 -11
  22. package/tools/TOOLS.md +7 -0
  23. package/worker/build-id.mjs +1 -1
  24. package/worker/canonical-json.mjs +6 -0
  25. package/worker/customer-mcp.mjs +1 -1
  26. package/worker/customer-resources.mjs +1 -1
  27. package/worker/event-webhook-config.mjs +8 -0
  28. package/worker/event-webhooks.mjs +71 -0
  29. package/worker/functions.mjs +8 -6
  30. package/worker/index.mjs +1 -14
  31. package/worker/mcp-tools.mjs +10 -2
  32. package/worker/office-page.mjs +1 -1
  33. package/worker/office.mjs +7 -14
  34. package/worker/parts-sale.mjs +1 -1
  35. package/worker/purchase-core.mjs +1 -1
  36. package/worker/purchase-crypto.mjs +1 -1
  37. package/worker/purchase-keys.mjs +1 -1
  38. package/worker/purchase-links.mjs +1 -1
  39. package/worker/purchase-owner.mjs +1 -1
  40. package/worker/purchase-routes.mjs +1 -1
  41. package/worker/purchase-store.mjs +1 -1
  42. package/worker/shop-page.mjs +5 -69
  43. package/worker/shop-rules.mjs +5 -33
  44. package/worker/shop-store.mjs +76 -56
  45. package/worker/shop.mjs +22 -78
  46. package/worker/version.mjs +1 -1
  47. package/worker/referrals.mjs +0 -460
package/lib/shop.mjs CHANGED
@@ -22,7 +22,6 @@ import { paidReleases } from './parts-upload.mjs';
22
22
  * homie-studio shop orders the latest orders (never a card or an email)
23
23
  * homie-studio shop refund <order> [--reason requested_by_customer|duplicate|fraudulent] [--note "<why>"]
24
24
  * ASKS the owner (a one-tap link): the AI never refunds by itself
25
- * homie-studio shop statements [--period YYYY-MM] [--send] the signed referral statements this studio owes
26
25
  *
27
26
  * The proof of ownership is the studio's own Cloudflare login, as for the office.
28
27
  */
@@ -139,7 +138,7 @@ export async function shopStatus(root, { url } = {}) {
139
138
  export async function shopOrders(root, { url } = {}) {
140
139
  const r = await withKey(root, url, (call) => call('/_studio/api/shop'));
141
140
  if (!r.ok) return { ok: false, command: 'shop orders', why: r.message ?? r.why ?? 'the studio did not answer' };
142
- return { ok: true, command: 'shop orders', orders: (r.orders ?? []).map((o) => ({ id: o.id, at: new Date(o.createdAt).toISOString(), item: o.item, name: o.name, shown: o.shown, status: o.status, mode: o.mode, player: o.player?.name ?? null, via: o.via, stripe: o.stripe, manualRefund: o.manualRefund ?? null })) };
141
+ return { ok: true, command: 'shop orders', orders: (r.orders ?? []).map((o) => ({ id: o.id, at: new Date(o.createdAt).toISOString(), item: o.item, name: o.name, shown: o.shown, status: o.status, mode: o.mode, player: o.player?.name ?? null, stripe: o.stripe, manualRefund: o.manualRefund ?? null })) };
143
142
  }
144
143
 
145
144
  /** The AI proposes a refund: an ask the owner confirms with one tap (an office key can never refund by itself). */
@@ -149,28 +148,6 @@ export async function shopRefund(root, order, { url, reason, note, manualTransac
149
148
  return askedFor(root, url, r, 'shop refund');
150
149
  }
151
150
 
152
- export async function shopStatements(root, { url, period, cursor = '', send = false } = {}) {
153
- if (!send) {
154
- const r = await withKey(root, url, (call) => call(`/_studio/api/shop/statements?cursor=${encodeURIComponent(cursor)}${period ? `&period=${encodeURIComponent(period)}` : ''}`));
155
- return r.ok ? { ok: true, command: 'shop statements', period: r.period, nextCursor: r.nextCursor, statements: r.statements } : { ok: false, command: 'shop statements', why: r.message ?? r.why ?? 'the studio did not answer' };
156
- }
157
- const sent = [];
158
- console.error('Sending statements. Keep this command open; run it again after closing to resume safely.');
159
- return withKey(root, url, async (call) => {
160
- do {
161
- const page = await call('/_studio/api/shop/statements/send', { cursor, ...(period ? { period } : {}) });
162
- if (!page.ok) return { ok: false, command: 'shop statements', sent, why: page.message ?? page.why ?? 'the studio did not answer' };
163
- for (const failure of page.failures ?? []) if (!sent.some((x) => !x.ok && x.via === failure.via)) { sent.push(failure); console.error(`${failure.via}: failed: ${failure.why ?? failure.status}`); }
164
- sent.push(...page.sent);
165
- for (const result of page.sent) console.error(`${result.via}: ${result.ok ? 'page sent' : 'failed: ' + (result.why ?? result.status)}`);
166
- period = page.period;
167
- cursor = page.nextCursor;
168
- if (page.retryAfter) await new Promise((resolve) => setTimeout(resolve, page.retryAfter * 1000));
169
- } while (cursor !== null);
170
- return { ok: true, command: 'shop statements', period, nextCursor: null, sent };
171
- });
172
- }
173
-
174
151
  /* ------------------------------------------------------------------ the key, from a page on this computer */
175
152
 
176
153
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
@@ -439,7 +416,7 @@ export function shopLines(r) {
439
416
  lines.push(`Webhook endpoint for Stripe: ${r.hook}`, `Payouts: ${r.stripe?.payouts}`);
440
417
  if (r.local && !r.local.ok) lines.push(`shop.json here: ${r.local.errors.map((e) => e.message).join('; ')}`);
441
418
  } else if (r.command === 'shop orders') {
442
- for (const o of r.orders) lines.push(`${o.id} ${o.at.slice(0, 16)} ${o.name} ${o.shown} ${o.status}${o.mode === 'test' ? ' (test)' : ''} ${o.player ?? '(deleted account)'}${o.via ? ` via ${o.via}` : ''}`);
419
+ for (const o of r.orders) lines.push(`${o.id} ${o.at.slice(0, 16)} ${o.name} ${o.shown} ${o.status}${o.mode === 'test' ? ' (test)' : ''} ${o.player ?? '(deleted account)'}`);
443
420
  if (!r.orders.length) lines.push('No orders yet.');
444
421
  } else if (r.command === 'shop check') {
445
422
  if (r.absent) lines.push(r.message);
@@ -451,11 +428,6 @@ export function shopLines(r) {
451
428
  }
452
429
  } else if (r.command === 'shop init') {
453
430
  lines.push(`Wrote ${r.wrote.join(', ')} (till: ${r.till}${r.items.length ? `; ${r.items.join(', ')}` : ''}). Next:`, ...r.next.map((n) => ` ${n}`));
454
- } else if (r.command === 'shop statements') {
455
- if (r.nextCursor) lines.push(`More rows: shop statements --period ${r.period} --cursor '${r.nextCursor.replaceAll("'", "'\\''")}'${r.sent ? ' --send' : ''}`);
456
- if (r.sent) for (const s of r.sent) lines.push(`${s.via}: ${s.ok ? 'sent' : `not sent (${s.why ?? s.status})`}`);
457
- else for (const s of r.statements ?? []) lines.push(`${s.statement.referrer}: due ${s.statement.totals ? money(s.statement.totals.due, s.statement.currency) : 'see first page'} (${s.statement.lines.length} line(s)), signed`);
458
- if (!(r.sent ?? r.statements ?? []).length) lines.push(`No referral statements for ${r.period}.`);
459
431
  } else { lines.push(r.message ?? r.why ?? (r.alreadyConnected ? 'Stripe test credentials are already installed.' : '')); for (const step of r.next ?? []) lines.push(step); if (r.fallback) lines.push(`Optional fallback, only if chosen: ${r.fallback}`); }
460
432
  return lines;
461
433
  }
@@ -3263,7 +3263,10 @@
3263
3263
  "## Art direction and models": "a48fff7bf0a5f2fc",
3264
3264
  "## Running live games (the back office)": "51ebf2ab756f6e4d",
3265
3265
  "## Servers and AI seats": "1ccfd549d2369a60",
3266
- "## Selling things (the shop)": "9e020d69dd6a954e",
3266
+ "## Selling things (the shop)": [
3267
+ "43a3556be93d1235",
3268
+ "9e020d69dd6a954e"
3269
+ ],
3267
3270
  "## Continuing a build from the Claude app": "0d68081e924ca419",
3268
3271
  "## Rules": "b2234d80f6cbe71d",
3269
3272
  "## Telling Homie": "80b46fdd9e578db8",
@@ -3321,6 +3324,90 @@
3321
3324
  }
3322
3325
  }
3323
3326
  }
3327
+ },
3328
+ "0.50.4": {
3329
+ "files": {
3330
+ "CLAUDE.md": "ddf2b2a0376202f6",
3331
+ "README.md": "684b3cca8001673c",
3332
+ "HANDOFF.md": "29337e2ace95b2e7",
3333
+ "games/README.md": "5ad32fe80c61f117",
3334
+ "music/README.md": "a3dc042dadd39df8",
3335
+ "videos/README.md": "16babc3437d5545b",
3336
+ "posts/README.md": "37841aae476bd540",
3337
+ "site/README.md": "1dbdf22c03a1e505",
3338
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3339
+ },
3340
+ "sections": {
3341
+ "AGENTS.md": {
3342
+ "(intro)": "394cbbe934e80722",
3343
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3344
+ "## Layout": "f3a280b8c96cd068",
3345
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3346
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3347
+ "## The site": "0cb8dd9a6f8a0096",
3348
+ "## Making games": "bb715272fd1a615b",
3349
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3350
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3351
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3352
+ "## Servers and AI seats": "1ccfd549d2369a60",
3353
+ "## Selling things (the shop)": "9e020d69dd6a954e",
3354
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3355
+ "## Rules": "b2234d80f6cbe71d",
3356
+ "## Telling Homie": "80b46fdd9e578db8",
3357
+ "## Beta": "9d02638207a26e73"
3358
+ }
3359
+ },
3360
+ "named": {
3361
+ "Homie Arcade": {
3362
+ "sections": {
3363
+ "AGENTS.md": {
3364
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3365
+ }
3366
+ }
3367
+ }
3368
+ }
3369
+ },
3370
+ "0.50.5": {
3371
+ "files": {
3372
+ "CLAUDE.md": "ddf2b2a0376202f6",
3373
+ "README.md": "684b3cca8001673c",
3374
+ "HANDOFF.md": "29337e2ace95b2e7",
3375
+ "games/README.md": "5ad32fe80c61f117",
3376
+ "music/README.md": "a3dc042dadd39df8",
3377
+ "videos/README.md": "16babc3437d5545b",
3378
+ "posts/README.md": "37841aae476bd540",
3379
+ "site/README.md": "1dbdf22c03a1e505",
3380
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3381
+ },
3382
+ "sections": {
3383
+ "AGENTS.md": {
3384
+ "(intro)": "394cbbe934e80722",
3385
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3386
+ "## Layout": "f3a280b8c96cd068",
3387
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3388
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3389
+ "## The site": "0cb8dd9a6f8a0096",
3390
+ "## Making games": "bb715272fd1a615b",
3391
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3392
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3393
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3394
+ "## Servers and AI seats": "1ccfd549d2369a60",
3395
+ "## Selling things (the shop)": "43a3556be93d1235",
3396
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3397
+ "## Rules": "b2234d80f6cbe71d",
3398
+ "## Telling Homie": "80b46fdd9e578db8",
3399
+ "## Beta": "9d02638207a26e73"
3400
+ }
3401
+ },
3402
+ "named": {
3403
+ "Homie Arcade": {
3404
+ "sections": {
3405
+ "AGENTS.md": {
3406
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3407
+ }
3408
+ }
3409
+ }
3410
+ }
3324
3411
  }
3325
3412
  }
3326
3413
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.50.3",
3
+ "version": "0.50.5",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/shop/SELLING.md CHANGED
@@ -4,7 +4,7 @@ You sell with your own Stripe account. Money goes directly to you; Homie takes n
4
4
  The studio is the seller and is responsible for the law where it sells and for Stripe's terms.
5
5
 
6
6
  Your `shop.json` chooses items, wording, prices, currency, tax integration, sale dates, entitlements,
7
- referral terms, optional spending caps and optional refund windows. Nothing requires an account or age
7
+ optional spending caps and optional refund windows. Nothing requires an account or age
8
8
  question by default. Guests can buy repeatedly and keep their purchases when signing in later.
9
9
 
10
10
  You can choose `policy.preset: "protective"` or `"adults-only"`, or set individual options.
package/shop/SHOP.md CHANGED
@@ -34,20 +34,16 @@ A kind is a label, not a recurring-billing, wallet, random-reward or fulfillment
34
34
  Its reservation releases only when Stripe confirms expiry or another terminal outcome.
35
35
  - `refundDays`: optional self-service refund window for items, including used items by default.
36
36
  A tip is never refunded by the player. The studio can refund any paid order or line from its office; disputed charges wait for Stripe to resolve the dispute.
37
- - `referrals`: optional `rate`, `windowDays`, `capPerPlayer`, `holdDays`, `minimumInvoice`,
38
- `billingEmail` and `accept`. `rate` defaults to zero until written;
39
- omitted limits do not apply. `referralNewPlayersOnly: true` limits attribution to new players.
40
37
  - `catalog`: modes (`test`, `live`) populated by `homie-studio shop catalog` for named Stripe Products.
41
38
  Prices always come from your file, even when a Stripe Product exists.
42
39
  - Optional flood protection: `purchaseAttemptsPerMinute`,
43
40
  `purchaseAttemptsPerAddressPerMinute`, `guestBuyersPerAddressPerHour`.
44
41
  Omitted or null means no limit. Explicit positive integers apply per Worker instance.
45
42
  An explicitly set `PLAYER_LIMIT_DAILY` also applies to new shop buyers.
46
- Referral delivery uses `REFERRAL_STATEMENTS_PER_MINUTE`, `REFERRAL_STATEMENTS_GLOBAL_PER_MINUTE`
47
- (both 30), and `REFERRAL_STATEMENT_BYTES` (65536). `requestBytes` overrides the catalog-sized shop request
43
+ `requestBytes` overrides the catalog-sized shop request
48
44
  allowance; `SHOP_WEBHOOK_BYTES` (524288) controls the webhook byte allowance.
49
45
 
50
- No toolkit ceiling applies to prices, quantities, tips, durations, refund windows, caps or referral terms.
46
+ No toolkit ceiling applies to prices, quantities, tips, durations, refund windows or caps.
51
47
  Money is integer Stripe currency units, with checked safe arithmetic. Stripe's currency rules and
52
48
  [Checkout's maximum of 100 lines](https://docs.stripe.com/api/checkout/sessions/create#line_items) apply.
53
49
  Free and paid lines can share a cart. An entirely free cart grants locally without a Stripe call. Product text limits and currency-unit requirements come from Stripe;
@@ -161,22 +157,23 @@ secret-returning webhook through a conversation tool: the private connect proces
161
157
 
162
158
  ## Payment guarantees and schema
163
159
 
164
- The schema step `0012_shop_lines.sql` follows the reservation and statement steps. Deploy applies it through
160
+ The schema step `0012_shop_lines.sql` follows the reservation step. Deploy applies it through
165
161
  the existing migration mechanism. It adds tables and columns without changing any existing primary key or
166
162
  column meaning. Released code keeps working after the step, including through rollback. The new Worker
167
163
  without the step still reads owned items and refunds from the office; new sales wait for the named step.
168
164
 
169
- A verified payment atomically grants all line snapshots and records the referral share on the order total.
165
+ `0022_remove_referrals.sql` removes the old referral tables, keys and sales-attribution columns; orders and entitlements remain intact. Old referral settings are ignored with a build note.
166
+
167
+ A verified payment atomically records the payment and grants all line snapshots.
170
168
  Duplicate events, reconciliation and late payments grant exactly once, including after a missing mark,
171
- owner release, expiry or failure. Refunds revoke only their own lines, retain other purchases of the same key,
172
- and adjust the referral share with exact integer allocation. Stripe tax is read per line when applicable.
169
+ owner release, expiry or failure. Refunds revoke only their own lines, and retain other purchases of the same key. Stripe tax is read per line when applicable.
173
170
  Snapshots preserve delivery after catalog edits; the schema step represents older orders as one line.
174
171
  An unpaid older order has no historical item snapshot and needs its catalog definition to complete delivery.
175
172
  A missing definition gives a named retryable response and office note; the office can refund the verified
176
173
  payment. Older paid orders missing a grant are repaired once. An unknown older item is refunded by the office,
177
174
  since its original kind cannot be recovered safely for player self-service.
178
175
  A whole-order refund uses one Stripe refund for the remaining charge. Dashboard partial refunds reduce cap
179
- usage and referral shares by their pre-tax portion, without assigning money to an unnamed line.
176
+ usage by their pre-tax portion, without assigning money to an unnamed line.
180
177
  Refunds are rebuilt from [Stripe’s refunds list](https://docs.stripe.com/api/refunds/list). Only
181
178
  [succeeded refunds](https://docs.stripe.com/api/refunds/object#refund_object-status) revoke items;
182
179
  pending refunds keep them and failed refunds restore them. A named line loses only its own items;
@@ -219,3 +216,5 @@ These calls open the same shop in games and apps. Inside a player card, purchase
219
216
  ## App payment references
220
217
 
221
218
  Create the booking/ticket/gift intention first. `shop.buy('full-tune', 1, undefined, { reference: booking.id })` or `shop.checkout({ reference: booking.id })` sends the app's own reference through checkout. Existing boolean returns are unchanged: checkout opened is not paid. The verified durable `order.paid` event carries `data.order`, `data.reference` (null if omitted) and `data.app`. Reopened Payment Links retain the reference. A reference is buyer-supplied correlation, not authorization: verify the record, app, amount and currency, deduplicate by event/order ID, then fulfil it. Keep personal data out of references. Before Stripe is connected, local prices and unpaid requests can be demonstrated; actual payment and recurring collection remain unverified. Test events must be labelled simulations.
219
+
220
+ For referrals, use Stripe Connect to split payments or a Stripe-based affiliate service; Homie builds none.
package/tools/TOOLS.md CHANGED
@@ -172,3 +172,10 @@ resources and event delivery.
172
172
  `version`, where the selected role declares `delete:<collection>`. Record conflict
173
173
  messages are returned intact so the AI can reload and retry; unexpected tool
174
174
  errors remain generic to avoid exposing secrets.
175
+
176
+ For clients that cannot keep a stream open, use the signed outbound webhook example
177
+ in `functions/FUNCTIONS.md`. Local MCP `studio_events_webhook` provisions, rotates
178
+ and removes secrets directly; remote owner tools `studio_events_webhook_status`
179
+ and `studio_events_webhook_test` inspect delivery and test it. The receiver verifies
180
+ Standard Webhooks signatures, deduplicates events, then wakes the owner's AI to use
181
+ its own service connectors.
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.50.3","sha256":"2099723ed724a66028f1cddaea2a5f9dc0c8616282096b6bc98fb7857a84a800"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.50.5","sha256":"11a7b1a55c4f2499f8a04e3f9808ed38f4dde1725f991fffdcfab47dc7a28ea0"});
@@ -0,0 +1,6 @@
1
+ /** JSON with sorted keys: what a signature covers, byte for byte, on both sides. */
2
+ export function canonicalJson(v) {
3
+ if (Array.isArray(v)) return `[${v.map(canonicalJson).join(',')}]`;
4
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(',')}}`;
5
+ return JSON.stringify(v ?? null);
6
+ }
@@ -1,5 +1,5 @@
1
1
  import {byClaim} from './purchase-store.mjs';
2
- import {canonicalJson} from './referrals.mjs';
2
+ import {canonicalJson} from './canonical-json.mjs';
3
3
  import {purchaseResponse} from './purchase-response.mjs';
4
4
  import {machineResource} from './purchase-machine.mjs';
5
5
  import {purchaseSchema} from './purchase-mcp.mjs';
@@ -4,7 +4,7 @@ import {registerResourceKind} from './resource-kinds.mjs';
4
4
  import {quoteHash,legalTerms} from './parts-sale.mjs';
5
5
  import {digest,manifestHash} from './purchase-crypto.mjs';
6
6
  import {offerVersion} from './purchase-core.mjs';
7
- import {canonicalJson} from './referrals.mjs';
7
+ import {canonicalJson} from './canonical-json.mjs';
8
8
  import {serviceTerms} from './service-terms.mjs';
9
9
  export {serviceTerms} from './service-terms.mjs';
10
10
  export async function pruneOffers(env,now=Date.now()) {
@@ -0,0 +1,8 @@
1
+ export const webhookName=(name,hook)=>'@webhook/'+name+'/'+hook.id;
2
+ export const webhookSecret=id=>'STUDIO_EVENT_WEBHOOK_'+id.toUpperCase();
3
+ export function validateWebhook(name,hook) {
4
+ if(!/^[a-z][a-z0-9-]*$/.test(name))throw new Error('Invalid event feed name');
5
+ let url;try{url=new URL(hook.url);}catch{throw new Error('Webhook requires an HTTPS URL');}
6
+ if(url.protocol!=='https:'||url.username||url.password||url.hash)throw new Error('Webhook requires HTTPS without credentials or a fragment');
7
+ if(!/^[a-f0-9]{32}$/.test(hook.id??'')||hook.secret!==webhookSecret(hook.id))throw new Error('Use events webhook add to provision this feed’s signing secret');
8
+ }
@@ -0,0 +1,71 @@
1
+ import {eventFeeds,matchesFeed} from './event-resources.mjs';
2
+ import {settingsOf,launchOf} from './office.mjs';
3
+
4
+ import {webhookName,validateWebhook} from './event-webhook-config.mjs';
5
+ // A webhook delegates the declared audience, never the configuring owner's wider access.
6
+ export async function readableWebhook(env,cat,feed) {
7
+ if(feed.event!=='record.changed')return (feed.audience??'owner')==='owner';
8
+ const app=cat.games.find(g=>g.id===feed.app&&g.kind==='app');
9
+ if(!app?.records?.collections?.[feed.collection])return false;
10
+ if((feed.audience??'owner')==='owner')return true;
11
+ return feed.audience?.app===app.id && app.roles?.[feed.audience.role]?.can?.includes('read:'+feed.collection)===true && launchOf(app,await settingsOf(env,{fresh:true}),env)==='public';
12
+ }
13
+ class WebhookFailure extends Error {
14
+ constructor(message){super(message);this.name='WebhookFailure';}
15
+ }
16
+ export function webhookFailure(error){return error instanceof WebhookFailure?error.message:'Webhook delivery failed';}
17
+ export const webhookBackoff=attempt=>Math.min(3600000,60000*2**Math.min(16,attempt-1));
18
+ export async function postWebhook(env,name,event,{fetchFn=globalThis.fetch.bind(globalThis),now=Date.now,timeoutMs=10000}={}) {
19
+ const hook=eventFeeds(env)[name]?.webhook;
20
+ if(!hook)throw new WebhookFailure('Webhook removed');
21
+ validateWebhook(name,hook);
22
+ const secret=env[hook.secret];
23
+ if(typeof secret!=='string'||!/^whsec_[A-Za-z0-9+/]{43}=$/.test(secret))throw new WebhookFailure('Webhook signing secret unavailable');
24
+ const key=await crypto.subtle.importKey('raw',Uint8Array.from(atob(secret.slice(6)),c=>c.charCodeAt(0)),{name:'HMAC',hash:'SHA-256'},false,['sign']);
25
+ // Encode the event ID to avoid ambiguous dot-delimited signatures and invalid headers.
26
+ const id='evt_'+btoa(String.fromCharCode(...new TextEncoder().encode(event.id))).replaceAll('+','-').replaceAll('/','_').replaceAll('=','');
27
+ const timestamp=String(Math.floor(now()/1000));
28
+ const body=JSON.stringify({id:event.id,type:event.type,at:event.at,data:event.data});
29
+ const signature=btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign('HMAC',key,new TextEncoder().encode(`${id}.${timestamp}.${body}`)))));
30
+ const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),timeoutMs);
31
+ try {
32
+ const response=await fetchFn(hook.url,{method:'POST',redirect:'manual',signal:controller.signal,headers:{'content-type':'application/json','webhook-id':id,'webhook-timestamp':timestamp,'webhook-signature':'v1,'+signature},body});
33
+ // Never read or log receiver content: it may echo credentials or private records.
34
+ await response.body?.cancel();
35
+ if(!response.ok)throw new WebhookFailure('Webhook HTTP '+response.status);
36
+ }catch(error){
37
+ if(error instanceof WebhookFailure)throw error;
38
+ throw new WebhookFailure(controller.signal.aborted?'Webhook timed out':'Webhook network failure');
39
+ }finally{clearTimeout(timer);}
40
+ }
41
+ export function webhookSubscriptions(env,cat,options={}) {
42
+ return Object.entries(eventFeeds(env)).filter(([,feed])=>feed.webhook).map(([name,feed])=>({
43
+ name:webhookName(name,feed.webhook),event:feed.event,webhook:true,
44
+ matches:event=>matchesFeed(feed,event),
45
+ async handler(event){
46
+ if(event.type!=='webhook.test'&&!matchesFeed(feed,event))return;
47
+ if(!await readableWebhook(env,cat,feed))throw new WebhookFailure('Feed audience cannot read this collection');
48
+ await postWebhook(env,name,event,options);
49
+ },
50
+ }));
51
+ }
52
+ export async function webhookStatus(env,name,after=0) {
53
+ const feed=eventFeeds(env)[name];if(!feed)throw new Error('Unknown event feed');
54
+ if(!feed.webhook)return {feed:name,enabled:false,latest:null,deliveries:[],next:null};
55
+ const rows=(await env.DB.prepare(`SELECT e.seq,d.event,d.state,d.attempts,d.error,d.due,d.completed FROM function_deliveries d JOIN studio_events e ON e.id=d.event WHERE d.function=?1 AND e.seq>?2 ORDER BY e.seq LIMIT 100`).bind(webhookName(name,feed.webhook),after).all()).results;
56
+ const latest=await env.DB.prepare('SELECT d.event,d.state,d.attempts,d.error,d.due,d.completed FROM function_deliveries d JOIN studio_events e ON e.id=d.event WHERE d.function=?1 ORDER BY e.seq DESC LIMIT 1').bind(webhookName(name,feed.webhook)).first();
57
+ return {feed:name,enabled:true,latest,deliveries:rows,next:rows.length===100?rows.at(-1).seq:null};
58
+ }
59
+ export async function testWebhook(env,cat,name,origin) {
60
+ const feed=eventFeeds(env)[name];if(!feed?.webhook)throw new Error('Webhook not configured');
61
+ if(!await readableWebhook(env,cat,feed))throw new Error('Feed audience cannot read this collection');
62
+ const {syncFunctions,runFunctions}=await import('./functions.mjs');await syncFunctions(env);
63
+ const id='webhook-test:'+crypto.randomUUID();
64
+ // Only the selected delivery sees this synthetic event; no feed subscribes to this type.
65
+ await env.DB.batch([
66
+ env.DB.prepare('INSERT INTO studio_events(id,type,data,at) VALUES(?1,?2,?3,?4)').bind(id,'webhook.test',JSON.stringify({feed:name,test:true}),Date.now()),
67
+ env.DB.prepare('INSERT INTO function_deliveries(event,function) VALUES(?1,?2)').bind(id,webhookName(name,feed.webhook)),
68
+ ]);
69
+ await runFunctions(env,cat,origin);
70
+ return {id,...await env.DB.prepare('SELECT state,attempts,error,due FROM function_deliveries WHERE event=?1 AND function=?2').bind(id,webhookName(name,feed.webhook)).first()};
71
+ }
@@ -1,3 +1,4 @@
1
+ import {webhookSubscriptions,webhookFailure,webhookBackoff} from './event-webhooks.mjs';
1
2
  import {feedSubscriptions,pushEvents} from './event-resources.mjs';
2
3
  import { services } from './mcp-tools.mjs';
3
4
  import { isOwner, sameOrigin } from './office.mjs';
@@ -7,7 +8,7 @@ export function useFunctions(loader) { load = loader; }
7
8
  export async function hasFunctionType(type,env={}) { return [...await load(),...feedSubscriptions(env)].some(fn=>fn.event===type); }
8
9
  export async function syncFunctions(env, definitions = null, now = Date.now()) {
9
10
  definitions ??= await load();
10
- definitions=[...definitions.filter(fn=>!fn.name.startsWith('@events/')),...feedSubscriptions(env)];
11
+ definitions=[...definitions.filter(fn=>!fn.name.startsWith('@events/')&&!fn.name.startsWith('@webhook/')),...feedSubscriptions(env),...webhookSubscriptions(env,null)];
11
12
  if (!env.DB || !definitions.length) return definitions;
12
13
  // Deploy owns the production registry; an old in-flight Worker must not overwrite it.
13
14
  if(await env.DB.prepare("SELECT 1 FROM meta WHERE key='function-registry-deployed'").first())return definitions;
@@ -30,17 +31,18 @@ export async function pruneEvents(env) {
30
31
  const cursor=Number((await env.DB.prepare("SELECT value FROM meta WHERE key='function-prune-cursor'").first())?.value??0);
31
32
  const rows=(await env.DB.prepare('SELECT seq,id FROM studio_events WHERE seq>?1 ORDER BY seq LIMIT 100').bind(cursor).all()).results;
32
33
  const statements=rows.flatMap(({id})=>[
33
- env.DB.prepare(`DELETE FROM function_deliveries WHERE event=?1 AND (state='complete' OR NOT EXISTS (SELECT 1 FROM function_subscriptions s WHERE s.name=function_deliveries.function))`).bind(id),
34
+ env.DB.prepare(`DELETE FROM function_deliveries WHERE event=?1 AND ((state='complete' AND function NOT LIKE '@webhook/%') OR NOT EXISTS (SELECT 1 FROM function_subscriptions s WHERE s.name=function_deliveries.function))`).bind(id),
34
35
  env.DB.prepare(`DELETE FROM studio_events WHERE id=?1 AND NOT EXISTS (SELECT 1 FROM function_subscriptions s WHERE s.type=studio_events.type AND s.cursor<studio_events.seq) AND NOT EXISTS (SELECT 1 FROM function_deliveries d WHERE d.event=studio_events.id)`).bind(id),
35
36
  ]);
36
37
  statements.push(env.DB.prepare("INSERT INTO meta(key,value) VALUES('function-prune-cursor',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(String(rows.at(-1)?.seq??0)));
37
38
  await env.DB.batch(statements);
38
39
  }
39
40
  /** A lease is for retry after a crashed Worker, not a deadline imposed on studio code. */
40
- export async function runFunctions(env, cat, origin, {definitions, now = Date.now, leaseMs = 60000} = {}) {
41
+ export async function runFunctions(env, cat, origin, {definitions, now = Date.now, leaseMs = 60000, webhookOptions = {}} = {}) {
41
42
  definitions = await syncFunctions(env,definitions,now());
42
43
  if(!definitions.length)return;
43
44
  if(typeof cat==='function')cat=await cat();
45
+ definitions=definitions.filter(fn=>!fn.webhook).concat(webhookSubscriptions(env,cat,webhookOptions));
44
46
  // An AI notification must not wait behind a slow function handler.
45
47
  const notify=()=>pushEvents(env,cat,origin).catch(error=>console.error('Event notification wake failed',error));
46
48
  const notifications=notify();
@@ -54,7 +56,7 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
54
56
  const page=(await env.DB.prepare('SELECT seq,id,data FROM studio_events WHERE type=?1 AND seq>?2 ORDER BY seq LIMIT 100').bind(fn.event,subscription.cursor).all()).results;
55
57
  if(page.length)more=true;
56
58
  if(page.length)await env.DB.batch([
57
- ...page.filter(e=>!fn.schedule||JSON.parse(e.data).function===fn.name).map(e=>env.DB.prepare('INSERT OR IGNORE INTO function_deliveries(event,function) SELECT id,?2 FROM studio_events WHERE id=?1').bind(e.id,fn.name)),
59
+ ...page.filter(e=>(!fn.schedule||JSON.parse(e.data).function===fn.name)&&(!fn.matches||fn.matches({type:fn.event,data:JSON.parse(e.data)}))).map(e=>env.DB.prepare('INSERT OR IGNORE INTO function_deliveries(event,function) SELECT id,?2 FROM studio_events WHERE id=?1').bind(e.id,fn.name)),
58
60
  env.DB.prepare('UPDATE function_subscriptions SET cursor=MAX(cursor,?2) WHERE name=?1').bind(fn.name,page.at(-1).seq),
59
61
  ]);
60
62
  const rows=(await env.DB.prepare(`SELECT d.*,e.type,e.data,e.at FROM function_deliveries d JOIN studio_events e ON e.id=d.event WHERE d.function=?1 AND d.state IN ('pending','failed','running') AND d.due<=?2 ORDER BY d.due LIMIT 100`).bind(fn.name,now()).all()).results;
@@ -67,9 +69,9 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
67
69
  const event=Object.freeze({id:row.event,type:row.type,at:row.at,data:JSON.parse(row.data),attempt:row.attempts+1});
68
70
  await fn.handler(event,services(env,cat,origin,{id:'studio-function',owner:true,client:null},'function:'+fn.name,fn));
69
71
  await env.DB.prepare(`UPDATE function_deliveries SET state='complete',completed=?4,error=NULL WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,now()).run();
70
- } catch {
72
+ } catch (error) {
71
73
  // Handler errors may contain credentials. The office identifies the function/event; Worker logs are the studio's debugging surface.
72
- await env.DB.prepare(`UPDATE function_deliveries SET state='failed',error='Function failed; inspect its code and retry',due=?4 WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,now()+leaseMs).run();
74
+ await env.DB.prepare(`UPDATE function_deliveries SET state='failed',error=?5,due=?4 WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,now()+(fn.webhook?webhookBackoff(row.attempts+1):leaseMs),fn.webhook?webhookFailure(error):'Function failed; inspect its code and retry').run();
73
75
  }
74
76
  }
75
77
  }
package/worker/index.mjs CHANGED
@@ -66,7 +66,6 @@ import { appRole, openPath } from './app-format.mjs';
66
66
  * /shop/, /api/shop/... the studio's shop (worker/shop.mjs, shop/SHOP.md): its items in real money, the studio's
67
67
  * OWN Stripe Checkout, the signed webhook, what a player owns (/api/player/owns), refunds;
68
68
  * closed until the owner's restricted key is in; never on a kids server or a television
69
- * /api/referrals/statement another studio's signed referral statement to this one (worker/referrals.mjs)
70
69
  *
71
70
  * A page in the studio's site/pages wins over the generated one at the same address. Every HTML answer is
72
71
  * `no-transform` (an edge in front of a custom domain injects nothing) and is never framed by another site.
@@ -130,7 +129,6 @@ import { STUDIO_VERSION_TAG } from './version.mjs';
130
129
  import { reconcileOrders, readiness, roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
131
130
  import { DISCOVERY_FILES, discoveryResponse, llmsTxt, robotsTxt, sitemapXml } from './discover.mjs';
132
131
  import { ldScript, studioNode } from './schema.mjs';
133
- import { arrivalCookie, manifestReferrals } from './referrals.mjs';
134
132
  import { licenseOf } from './license.mjs';
135
133
  // GAME PARTS (parts/PARTS.md section 4): three call-outs below, everything else is worker/parts.mjs.
136
134
  import { PART_FILES, isPartsPath, partsRoutes, withPartsBand } from './parts.mjs';
@@ -647,15 +645,6 @@ export default {
647
645
  }
648
646
  }
649
647
  if (appLobby || appRecords) return appCors(request, res);
650
- // A referral's arrival (worker/referrals.mjs): only a person's page load with ?via= of another site, on a studio
651
- // that pays referrals. The page is answered first; a cookie is added to it only then.
652
- if (url.searchParams.has('via') && res && res.status === 200 && /text\/html/i.test(res.headers.get('content-type') ?? '')) {
653
- try {
654
- const cat = await catalogue(env, url.origin);
655
- const cookie = await arrivalCookie(request, env, url, shopOf(cat));
656
- if (cookie) { const out = new Response(res.body, res); out.headers.append('set-cookie', cookie); return out; }
657
- } catch { /* an arrival is never worth a failed page */ }
658
- }
659
648
  return res;
660
649
  },
661
650
  };
@@ -770,10 +759,8 @@ async function route(request, env, ctx) {
770
759
  ...(cat.studio?.lounge ? { lounge: { name: cat.studio.lounge.name, page: `${url.origin}/lounge/`, now: `${url.origin}/lounge/api/now` } } : {}),
771
760
  // Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
772
761
  ...(played ? { played } : {}),
773
- // The shop (0.24.0): whether it sells and its till, never a key or a sale; and referrals: this studio takes
774
- // signed statements as a referrer, with the public half of its statement key and, if it sells, its terms.
762
+ // The shop: whether it sells and its till, never a key or a sale.
775
763
  ...(shopOf(cat)?.open ? { shop: { open: true, till: shopOf(cat).till, currency: shopOf(cat).currency, page: `${url.origin}/shop/` } } : {}),
776
- ...(await manifestReferrals(env, url.origin, shopOf(cat), cat.studio).then((r) => (r ? { referrals: r } : {})).catch(() => ({}))),
777
764
  }, 200, { 'access-control-allow-origin': '*' });
778
765
  }
779
766
  if (path === '/api/games') {
@@ -79,6 +79,14 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
79
79
  export function builtins(env,cat,origin) {
80
80
  const tool=(name,description,audience,inputSchema,handler)=>({name,description,audience,inputSchema,handler});
81
81
  const tools=[
82
+ tool('studio_events_webhook_status','Owner delivery attempts, last errors and next page for an outbound event webhook.','owner',object({feed:str,after:{type:'integer',minimum:0}},['feed']),async({feed,after},ctx)=>{
83
+ if(!ctx.caller.owner)throw new Error('Owner only');
84
+ return (await import('./event-webhooks.mjs')).webhookStatus(env,feed,after);
85
+ }),
86
+ tool('studio_events_webhook_test','Send an explicitly synthetic event to the selected webhook using durable retries.','owner',object({feed:str},['feed']),async({feed},ctx)=>{
87
+ if(!ctx.caller.owner)throw new Error('Owner only');
88
+ return (await import('./event-webhooks.mjs')).testWebhook(env,cat,feed,origin);
89
+ }),
82
90
  tool('studio_function_replay','Replay retained events for a declaration with replay: true.','owner',object({name:str},['name']),async({name})=>{
83
91
  const {replayFunction,runFunctions}=await import('./functions.mjs');
84
92
  await replayFunction(env,name);await runFunctions(env,cat,origin);return {ok:true};
@@ -118,9 +126,9 @@ export function builtins(env,cat,origin) {
118
126
  for(const [name,path,description] of [
119
127
  ['room_announce','announce','Announce to a room.'],['room_kick','kick','Ask the owner to remove a player.'],['room_mute','mute','Ask the owner to mute a player.'],['room_close','close','Ask the owner to close a room.'],['studio_game','game','Ask the owner to change game access or room size.'],['shop_refund','shop/refund','Ask the owner to refund an order.'],
120
128
  ]) tools.push(tool(name,description,'owner',object({action:{type:'object'}},['action']),({action},ctx)=>ctx.office('/_studio/api/'+path,action)));
121
- const reads={rooms:'office',servers:'servers',members:'servers/members',chat:'chat',lounge:'lounge',shop:'shop',orders:'shop/orders.csv',lines:'shop/lines',received:'shop/received',statements:'shop/statements',invites:'invites'};
129
+ const reads={rooms:'office',servers:'servers',members:'servers/members',chat:'chat',lounge:'lounge',shop:'shop',orders:'shop/orders.csv',invites:'invites'};
122
130
  tools.push(tool('studio_office_read','Read an office view; use query for its existing filters and pagination.','owner',object({view:{enum:Object.keys(reads)},query:{type:'object',additionalProperties:{type:'string'}}},['view']),({view,query},ctx)=>ctx.office('/_studio/api/'+reads[view],undefined,query)));
123
- const actions=['announce','kick','mute','close','game','servers','servers/set','servers/close','servers/member','room-level','agents/brain','chat/rules','chat/remove','chat/report','chat/budget','lounge/rules','lounge/night','lounge/mod','lounge/remove','lounge/hold','shop/release','shop/refund','shop/settle','invites','invites/revoke'];
131
+ const actions=['announce','kick','mute','close','game','servers','servers/set','servers/close','servers/member','room-level','agents/brain','chat/rules','chat/remove','chat/report','chat/budget','lounge/rules','lounge/night','lounge/mod','lounge/remove','lounge/hold','shop/release','shop/refund','invites','invites/revoke'];
124
132
  tools.push(tool('studio_office_action','Use an existing office control. Destructive changes return a browser confirmation for the owner.','owner',object({operation:{enum:actions},action:{type:'object'}},['operation','action']),({operation,action},ctx)=>ctx.office('/_studio/api/'+operation,action)));
125
133
  for (const op of ['sit','look','act','speak','stand']) tools.push(tool('agent_'+op, 'A marked AI guide: '+op+'. Room policy and declared vocabulary apply.', 'signed-in', object({game:str,role:str,server:str,room:str,goal:str,line:str,args:{type:'object'}},['game']), async (args,ctx)=>(await import('./mcp-room.mjs')).remoteSeat(env,cat,ctx.caller,op,args)));
126
134
  tools.push(...selling.customerTools(env,cat,origin));
@@ -978,7 +978,7 @@ export function lockedPage(cat, { what = 'office', ask = null } = {}) {
978
978
  return new Response(shell(cat, `${what === 'confirm' ? 'Confirm' : 'Office'} · private`, body), { status: 401, headers: { ...PRIVATE, 'content-security-policy': NO_SCRIPT_CSP } });
979
979
  }
980
980
 
981
- const VERB = { refund: 'Refund it', 'shop-settle': 'Mark it paid', kick: 'Kick', mute: 'Mute', close: 'Close the room', announce: 'Announce', game: 'Change it', 'server-set': 'Change it', 'server-close': 'Close it', member: 'Remove', 'agents-brain': 'Let them talk' };
981
+ const VERB = { refund: 'Refund it', kick: 'Kick', mute: 'Mute', close: 'Close the room', announce: 'Announce', game: 'Change it', 'server-set': 'Change it', 'server-close': 'Close it', member: 'Remove', 'agents-brain': 'Let them talk' };
982
982
 
983
983
  /** The owner's one tap for what their AI asked, and what came of it. */
984
984
  export function confirmPage(cat, ask, { missing = false } = {}) {
package/worker/office.mjs CHANGED
@@ -58,8 +58,8 @@ import { toolIdentity } from './tool-identity.mjs';
58
58
  * taking a message down, happens at once.
59
59
  *
60
60
  * THE SHOP (0.24.0, worker/shop.mjs): /_studio/office/shop and /_studio/api/shop (sales, refunds, disputes, payouts as
61
- * links to the studio's own Stripe, a CSV, the referral books and signed statements). A refund is the owner's one tap;
62
- * from an office key it is ALWAYS an ask (the AI may only propose one), and so is marking a referrer paid.
61
+ * links to the studio's own Stripe, a CSV, order records). A refund is the owner's one tap;
62
+ * from an office key it is ALWAYS an ask (the AI may only propose one).
63
63
  */
64
64
  import { seatsOf } from './seats.mjs';
65
65
  import { diagnosticsOf, gatePlayers, roomLayout } from './gate.mjs';
@@ -71,7 +71,7 @@ import { esc, layout, notFoundPage } from './site.mjs';
71
71
  import { confirmPage, lockedPage, officePage } from './office-page.mjs';
72
72
  import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
73
73
  import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
74
- import { linesOf, checkRelease, performRelease, checkRefund, checkSettle, describeRefund, officeShopPage, ordersCsv, performRefund, receivedPages, settleReferrer, shopOffice, statementsOut, statementsSend } from './shop.mjs';
74
+ import { checkRelease, performRelease, checkRefund, describeRefund, officeShopPage, ordersCsv, performRefund, shopOffice } from './shop.mjs';
75
75
  import { orderById } from './shop-store.mjs';
76
76
  import { checkLoungeAction, describeLounge, loungeNeedsAsk, loungeOffice, performLounge } from './lounge.mjs';
77
77
 
@@ -766,7 +766,6 @@ function checkAction(cat, op, body) {
766
766
  }
767
767
  case 'shop-release': return checkRelease(body);
768
768
  case 'refund': return checkRefund(body);
769
- case 'shop-settle': return checkSettle(body);
770
769
  case 'agents-brain': {
771
770
  if (!meta) return bad('game is one of this studio\'s game ids');
772
771
  if (typeof body.server !== 'string' || !(body.server === 'public' || /^[a-z0-9][a-z0-9-]{1,19}$/.test(body.server))) return bad('server is the server\'s id');
@@ -868,7 +867,6 @@ export function describe(cat, a) {
868
867
  case 'room-level': return `Set the AI in ${where} to ${levelName(a.level)} (level ${a.level}).`;
869
868
  case 'shop-release': return `Release reservation ${a.order}: it stops counting toward the cap. We ask Stripe to expire an open Checkout Session. This does not refund a payment. A payment that still arrives grants the item, counts toward spending and can be refunded from the office.`;
870
869
  case 'refund': return describeRefund(cat, a, a.item ? { item: a.item, amount: a.amount, currency: a.currency } : null);
871
- case 'shop-settle': return `Mark what this studio owes ${a.via} for referrals in ${a.currency.toUpperCase()} as paid${a.ref ? ` (${a.ref})` : ''}: its owed lines are settled in the books.`;
872
870
  case 'agents-brain': return ['workers-ai', 'owner-key'].includes(a.mode)
873
871
  ? `Let the AI guides on ${gname}'s server ${a.server} talk: they speak only the lines the game's own agents.json gives them (never free text), at most one line every 8 seconds, never about a person, and a player can quiet them. Their brain runs on ${a.mode === 'workers-ai' ? `this studio's own Workers AI (free allowance${a.budget !== undefined ? `; at most ${Math.round(a.budget).toLocaleString('en-US')} neurons a day` : ''})` : `your own AI provider key (claude-haiku-4-5, your money${a.budget !== undefined ? `, at most $${Number(a.budget).toFixed(2)} a day` : ', capped daily'})`}.`
874
872
  : `Set the AI guides' brain on ${gname}'s server ${a.server} to ${a.mode}.`;
@@ -901,7 +899,7 @@ export async function needsAsk(env, cat, a) {
901
899
  if (String(a.op).startsWith('lounge-')) return loungeNeedsAsk(env, cat, a, chatOpensUp);
902
900
  if (DESTRUCTIVE.has(a.op)) return true;
903
901
  // Money: the owner's AI may only propose a refund or a settlement; the owner says yes.
904
- if (a.op === 'refund' || a.op === 'shop-settle' || a.op === 'shop-release') return true;
902
+ if (a.op === 'refund' || a.op === 'shop-release') return true;
905
903
  if (a.op === 'server-close') return !a.reopen;
906
904
  if (a.op === 'member') return a.remove === true;
907
905
  if (a.op === 'server-set') {
@@ -1134,7 +1132,6 @@ export async function perform(env, cat, a) {
1134
1132
  }
1135
1133
  case 'shop-release': return performRelease(env, a, cat);
1136
1134
  case 'refund': return performRefund(env, cat, a);
1137
- case 'shop-settle': return settleReferrer(env, a);
1138
1135
  default: return { ok: false, error: 'op' };
1139
1136
  }
1140
1137
  }
@@ -1284,24 +1281,20 @@ async function api(request, env, url, cat) {
1284
1281
  const lounge = await loungeOffice(env, cat, url.origin);
1285
1282
  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);
1286
1283
  }
1287
- // The shop (0.24.0): the owner's view, the accountant's CSV, the referral statements.
1284
+ // The shop (0.24.0): the owner's view, the accountant's CSV, order records.
1288
1285
  if (selling.enabled) { const result = await selling.officeRoutes(request, env, path, body); if (result) return result; }
1289
- 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));
1286
+ if (path === '/_studio/api/shop' && request.method === 'GET') return json(await shopOffice(env, cat, url.origin, /^\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));
1290
1287
  if (path === '/_studio/api/shop/orders.csv' && request.method === 'GET') {
1291
1288
  try {
1292
1289
  return new Response(await ordersCsv(env), { headers: { 'content-type': 'text/csv; charset=utf-8', 'cache-control': 'no-store, private', 'content-disposition': `attachment; filename="orders-${today()}.csv"` } });
1293
1290
  } catch { return json({ ok: false, error: 'not-migrated', message: 'The shop needs migration 0008_studio_shop.sql (npm run deploy).' }, 503); }
1294
1291
  }
1295
- if (path === '/_studio/api/shop/lines' && request.method === 'GET') return json({ ok: true, ...(await linesOf(env, { via: url.searchParams.get('via'), currency: url.searchParams.get('currency'), cursor: url.searchParams.get('cursor') ?? '' })) });
1296
- if (path === '/_studio/api/shop/received' && request.method === 'GET') return json(await receivedPages(env, url.searchParams.get('seller') ?? '', url.searchParams.get('period') ?? '', url.searchParams.get('cursor') ?? ''));
1297
- if (path === '/_studio/api/shop/statements' && request.method === 'GET') return json(await statementsOut(env, cat, url.origin, url.searchParams.get('period'), url.searchParams.get('cursor') ?? ''));
1298
- if (path === '/_studio/api/shop/statements/send' && request.method === 'POST') return json(await statementsSend(env, cat, url.origin, body.period, body.cursor ?? ''));
1299
1292
  const OPS = {
1300
1293
  '/_studio/api/chat/rules': 'chat-rules', '/_studio/api/chat/remove': 'chat-remove', '/_studio/api/chat/report': 'chat-report', '/_studio/api/chat/budget': 'chat-budget',
1301
1294
  '/_studio/api/kick': 'kick', '/_studio/api/mute': 'mute', '/_studio/api/close': 'close', '/_studio/api/announce': 'announce', '/_studio/api/game': 'game',
1302
1295
  '/_studio/api/servers': 'server-create', '/_studio/api/servers/set': 'server-set', '/_studio/api/servers/close': 'server-close', '/_studio/api/servers/member': 'member',
1303
1296
  '/_studio/api/agents/pass': 'pass', '/_studio/api/room-level': 'room-level', '/_studio/api/agents/brain': 'agents-brain',
1304
- '/_studio/api/shop/release': 'shop-release', '/_studio/api/shop/refund': 'refund', '/_studio/api/shop/settle': 'shop-settle',
1297
+ '/_studio/api/shop/release': 'shop-release', '/_studio/api/shop/refund': 'refund',
1305
1298
  '/_studio/api/lounge/rules': 'lounge-rules', '/_studio/api/lounge/night': 'lounge-night', '/_studio/api/lounge/mod': 'lounge-mod',
1306
1299
  '/_studio/api/lounge/remove': 'lounge-remove', '/_studio/api/lounge/hold': 'lounge-hold',
1307
1300
  };