@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.
- package/CHANGELOG.md +32 -0
- package/README.md +1 -2
- package/bin/homie-studio.mjs +8 -6
- package/functions/FUNCTIONS.md +125 -2
- package/guides/app/SKILL.md +14 -1
- package/guides/office/SKILL.md +14 -1
- package/guides/servers/SKILL.md +14 -1
- package/guides/shop/SKILL.md +4 -3
- package/lib/build.mjs +2 -3
- package/lib/events-cli.mjs +37 -0
- package/lib/functions-build.mjs +3 -1
- package/lib/mcp-tools.mjs +11 -0
- package/lib/parts-build.mjs +1 -1
- package/lib/parts.mjs +1 -1
- package/lib/purchase-links.mjs +1 -1
- package/lib/scaffold.mjs +11 -11
- package/lib/shop.mjs +2 -30
- package/lib/template-history.json +88 -1
- package/package.json +1 -1
- package/shop/SELLING.md +1 -1
- package/shop/SHOP.md +10 -11
- package/tools/TOOLS.md +7 -0
- package/worker/build-id.mjs +1 -1
- package/worker/canonical-json.mjs +6 -0
- package/worker/customer-mcp.mjs +1 -1
- package/worker/customer-resources.mjs +1 -1
- package/worker/event-webhook-config.mjs +8 -0
- package/worker/event-webhooks.mjs +71 -0
- package/worker/functions.mjs +8 -6
- package/worker/index.mjs +1 -14
- package/worker/mcp-tools.mjs +10 -2
- package/worker/office-page.mjs +1 -1
- package/worker/office.mjs +7 -14
- package/worker/parts-sale.mjs +1 -1
- package/worker/purchase-core.mjs +1 -1
- package/worker/purchase-crypto.mjs +1 -1
- package/worker/purchase-keys.mjs +1 -1
- package/worker/purchase-links.mjs +1 -1
- package/worker/purchase-owner.mjs +1 -1
- package/worker/purchase-routes.mjs +1 -1
- package/worker/purchase-store.mjs +1 -1
- package/worker/shop-page.mjs +5 -69
- package/worker/shop-rules.mjs +5 -33
- package/worker/shop-store.mjs +76 -56
- package/worker/shop.mjs +22 -78
- package/worker/version.mjs +1 -1
- 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,
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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)'}
|
|
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)":
|
|
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
|
+
"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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
package/worker/build-id.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/worker/customer-mcp.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {byClaim} from './purchase-store.mjs';
|
|
2
|
-
import {canonicalJson} from './
|
|
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 './
|
|
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
|
+
}
|
package/worker/functions.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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') {
|
package/worker/mcp-tools.mjs
CHANGED
|
@@ -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',
|
|
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','
|
|
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));
|
package/worker/office-page.mjs
CHANGED
|
@@ -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',
|
|
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,
|
|
62
|
-
* from an office key it is ALWAYS an ask (the AI may only propose one)
|
|
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 {
|
|
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-
|
|
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,
|
|
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,
|
|
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',
|
|
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
|
};
|