@homie-rocks/studio 0.50.4 → 0.50.6
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 +36 -0
- package/README.md +1 -2
- package/bin/homie-studio.mjs +7 -7
- package/functions/FUNCTIONS.md +69 -13
- package/guides/office/SKILL.md +17 -0
- package/guides/servers/SKILL.md +17 -0
- package/guides/shop/SKILL.md +4 -3
- package/lib/build.mjs +3 -4
- package/lib/cloudflare.mjs +8 -0
- package/lib/functions-build.mjs +10 -3
- package/lib/functions-cli.mjs +9 -3
- package/lib/parts-build.mjs +1 -1
- package/lib/parts.mjs +1 -1
- package/lib/purchase-links.mjs +1 -1
- package/lib/scaffold.mjs +18 -12
- package/lib/shop.mjs +2 -30
- package/lib/template-history.json +88 -1
- package/lib/worker-config.mjs +20 -3
- package/package.json +1 -1
- package/shop/SELLING.md +1 -1
- package/shop/SHOP.md +11 -12
- 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/function-schedule.mjs +68 -0
- package/worker/functions-schema.mjs +13 -0
- package/worker/functions.mjs +45 -9
- package/worker/index.mjs +1 -14
- package/worker/mcp-tools.mjs +7 -3
- 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",
|
|
@@ -3363,6 +3366,90 @@
|
|
|
3363
3366
|
}
|
|
3364
3367
|
}
|
|
3365
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
|
+
}
|
|
3411
|
+
},
|
|
3412
|
+
"0.50.6": {
|
|
3413
|
+
"files": {
|
|
3414
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3415
|
+
"README.md": "684b3cca8001673c",
|
|
3416
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3417
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3418
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3419
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3420
|
+
"posts/README.md": "37841aae476bd540",
|
|
3421
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3422
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3423
|
+
},
|
|
3424
|
+
"sections": {
|
|
3425
|
+
"AGENTS.md": {
|
|
3426
|
+
"(intro)": "394cbbe934e80722",
|
|
3427
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3428
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3429
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3430
|
+
"## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
|
|
3431
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3432
|
+
"## Making games": "bb715272fd1a615b",
|
|
3433
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3434
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3435
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3436
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3437
|
+
"## Selling things (the shop)": "43a3556be93d1235",
|
|
3438
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3439
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3440
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3441
|
+
"## Beta": "9d02638207a26e73"
|
|
3442
|
+
}
|
|
3443
|
+
},
|
|
3444
|
+
"named": {
|
|
3445
|
+
"Homie Arcade": {
|
|
3446
|
+
"sections": {
|
|
3447
|
+
"AGENTS.md": {
|
|
3448
|
+
"## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3366
3453
|
}
|
|
3367
3454
|
}
|
|
3368
3455
|
}
|
package/lib/worker-config.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import {readFileSync,writeFileSync,existsSync,renameSync} from 'node:fs';
|
|
|
2
2
|
import {join} from 'node:path';
|
|
3
3
|
import {configPath,readStudio,layoutOf,listExperiences} from './studio.mjs';
|
|
4
4
|
import {wranglerConfig} from './scaffold.mjs';
|
|
5
|
+
import {scheduleTrigger} from '../worker/function-schedule.mjs';
|
|
5
6
|
import {paidReleases} from './parts-upload.mjs';
|
|
6
7
|
const parse=t=>JSON.parse(t.replace(/^\s*\/\/.*$/gm,''));
|
|
7
8
|
const read=file=>existsSync(file)?parse(readFileSync(file,'utf8')):{};
|
|
@@ -10,9 +11,25 @@ function merge(base,custom) {
|
|
|
10
11
|
if(custom && typeof custom==='object')return Object.fromEntries([...new Set([...Object.keys(base??{}),...Object.keys(custom)])].sort().map(k=>[k,k in custom?merge(base?.[k],custom[k]):base[k]]));
|
|
11
12
|
return custom;
|
|
12
13
|
}
|
|
13
|
-
/**
|
|
14
|
+
/** Regenerate from declarations/custom config; migrate only newly encountered manual crons. */
|
|
14
15
|
export function updateWorkerConfig(root,generated) {
|
|
15
|
-
const file=configPath(root),
|
|
16
|
+
const file=configPath(root),base=parse(generated),custom=read(join(root,'wrangler.custom.json'));
|
|
17
|
+
// Preserve studio cron edits made before/after generation, including old wrangler files.
|
|
18
|
+
// Compare against the previous output to discover manual additions. Custom entries
|
|
19
|
+
// retain ownership even when their text equals a generated tick.
|
|
20
|
+
const previous=read(file);
|
|
21
|
+
let previousCrons=[];
|
|
22
|
+
try {previousCrons=JSON.parse(previous.vars?.STUDIO_FUNCTION_CRONS??'[]');}catch {}
|
|
23
|
+
const extra=(previous.triggers?.crons??[]).filter(c=>!previousCrons.includes(c)&&!(custom.triggers?.crons??[]).includes(c));
|
|
24
|
+
if(extra.length) {
|
|
25
|
+
custom.triggers={...custom.triggers,crons:[...new Set([...(custom.triggers?.crons??[]),...extra])].sort()};
|
|
26
|
+
writeFileSync(join(root,'wrangler.custom.json'),JSON.stringify(custom,null,2)+'\n');
|
|
27
|
+
}
|
|
28
|
+
const next=merge(base,custom);
|
|
29
|
+
// Crons are additive: studio-owned entries cannot erase required toolkit ticks.
|
|
30
|
+
const crons=[...new Set([...(base.triggers?.crons??[]),...(custom.triggers?.crons??[])])].sort();
|
|
31
|
+
next.triggers={...next.triggers,crons}; // [] explicitly removes old generated triggers in Wrangler.
|
|
32
|
+
next.vars={...next.vars,STUDIO_FUNCTION_CRONS:JSON.stringify(crons),STUDIO_FUNCTION_MANAGED_CRONS:JSON.stringify(base.triggers?.crons??[])};
|
|
16
33
|
const text='// Generated by homie-studio. Edit studio.json or wrangler.custom.json, then build.\n'+JSON.stringify(next,null,2)+'\n';
|
|
17
34
|
if(!existsSync(file)||readFileSync(file,'utf8')!==text) {const temp=`${file}.${process.pid}.tmp`;writeFileSync(temp,text);renameSync(temp,file);}
|
|
18
35
|
}
|
|
@@ -24,5 +41,5 @@ export function refreshWorkerConfig(root,overrides={}) {
|
|
|
24
41
|
const tools=read(join(root,'site/src/tools/declarations.json'));
|
|
25
42
|
const ai=cf.ai??listExperiences(root).some(g=>g.decide===true || g.chat!==false && (g.chat?.mode??'text')==='text' && g.chat?.ai!==false);
|
|
26
43
|
const paid=paidReleases(root).length>0||existsSync(join(root,'shop.json'))||tools.paid===true;
|
|
27
|
-
updateWorkerConfig(root,wranglerConfig({worker:cf.worker,name:studio.name,d1:cf.d1,d1Id:cf.d1Id,r2:cf.r2,layout:layoutOf(root),ai,paidParts:paid,partsRateLimit:cf.purchaseRateLimit??null,routes:cf.routes??null,eventFeeds:studio.mcp?.events,triggers:functions
|
|
44
|
+
updateWorkerConfig(root,wranglerConfig({worker:cf.worker,name:studio.name,d1:cf.d1,d1Id:cf.d1Id,r2:cf.r2,layout:layoutOf(root),ai,paidParts:paid,partsRateLimit:cf.purchaseRateLimit??null,routes:cf.routes??null,eventFeeds:studio.mcp?.events,triggers:{crons:[scheduleTrigger(functions,{eventFeeds:Object.keys(studio.mcp?.events??{}).length>0})].filter(Boolean)},...overrides}));
|
|
28
45
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.50.
|
|
3
|
+
"version": "0.50.6",
|
|
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;
|
|
@@ -191,7 +188,7 @@ and [expiring open sessions](https://docs.stripe.com/api/checkout/sessions/expir
|
|
|
191
188
|
Replacing an open checkout expires only reservations at least a minute old; a named cancellation can expire immediately.
|
|
192
189
|
Unresolved orders become eligible for reconciliation after one minute, even with no cap. A buyer's next shop
|
|
193
190
|
or owned-items request schedules up to three reads off the response path, with atomic claims and backoff up
|
|
194
|
-
to an hour; an optional Worker cron invokes the same reconciliation across buyers.
|
|
191
|
+
to an hour; an optional Worker cron invokes the same reconciliation across buyers. Shop/paid-part studios install a five-minute maintenance trigger. Declared functions install their own required scheduling/retry trigger. Put additional studio crons in root `wrangler.custom.json`; build and deploy merge and preserve them.
|
|
195
192
|
With an API key, every recorded payment checks Stripe’s refunds and dispute state, including payments recovered by an early refund or dispute event. The thanks page can verify a payment directly with the studio's key. Webhook events require Stripe's signature.
|
|
196
193
|
Test and live books stay separate. Owner release attempts to expire an open session, records who released it
|
|
197
194
|
and when, and never prevents a later verified payment grant.
|
|
@@ -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/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.6","sha256":"8aabf4298c649e24766b5dbd5836625be78a4c69c1005c221d93d4c9e4e45b3e"});
|
|
@@ -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,68 @@
|
|
|
1
|
+
// Cloudflare's five-field UTC cron dialect: Sunday=1, Saturday=7.
|
|
2
|
+
const ranges=[[0,59],[0,23],[1,31],[1,12],[1,7]];
|
|
3
|
+
const aliases=[null,null,null,['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'],['SUN','MON','TUE','WED','THU','FRI','SAT']];
|
|
4
|
+
function field(text,index) {
|
|
5
|
+
const [min,max]=ranges[index],values=new Set(),special=[];
|
|
6
|
+
text=text.toUpperCase();
|
|
7
|
+
if(aliases[index])text=text.replace(/[A-Z]{3}/g,name=>{const n=aliases[index].indexOf(name);if(n<0)throw new Error(`Unknown cron name ${name}`);return String(n+1);});
|
|
8
|
+
for(const part of text.split(',')) {
|
|
9
|
+
if(index===2 && /^(L|LW|(?:[1-9]|[12]\d|3[01])W)$/.test(part) || index===4 && /^[1-7](L|#[1-5])$/.test(part)) {special.push(part);continue;}
|
|
10
|
+
if(!/^(\*|\d+(?:-\d+)?)(?:\/[1-9]\d*)?$/.test(part))throw new Error(`Invalid cron field ${text}`);
|
|
11
|
+
const [base,stepText]=part.split('/'),step=Number(stepText??1);
|
|
12
|
+
const [lo,hi]=base==='*'?[min,max]:base.includes('-')?base.split('-').map(Number):[Number(base),stepText?max:Number(base)];
|
|
13
|
+
if(lo<min||hi>max||lo>hi||!Number.isSafeInteger(step)||step>max-min+1)throw new Error(`Cron field out of range: ${text}`);
|
|
14
|
+
for(let n=lo;n<=hi;n+=step)values.add(n);
|
|
15
|
+
}
|
|
16
|
+
return {values:[...values].sort((a,b)=>a-b),special};
|
|
17
|
+
}
|
|
18
|
+
const lastDay=d=>new Date(Date.UTC(d.getUTCFullYear(),d.getUTCMonth()+1,0)).getUTCDate();
|
|
19
|
+
function weekdayNear(d,day) {
|
|
20
|
+
const last=lastDay(d);if(day>last)return null;
|
|
21
|
+
const weekday=new Date(Date.UTC(d.getUTCFullYear(),d.getUTCMonth(),day)).getUTCDay();
|
|
22
|
+
return weekday===6?(day===1?3:day-1):weekday===0?(day===last?day-2:day+1):day;
|
|
23
|
+
}
|
|
24
|
+
export function parseSchedule(expression) {
|
|
25
|
+
if(typeof expression!=='string'||expression.trim().split(/\s+/).length!==5)throw new Error('Schedule needs five cron fields');
|
|
26
|
+
const parts=expression.trim().split(/\s+/),fields=parts.map(field);
|
|
27
|
+
const dayMatches=d=>{
|
|
28
|
+
const day=d.getUTCDate(),weekday=d.getUTCDay()+1,last=lastDay(d);
|
|
29
|
+
return fields[3].values.includes(d.getUTCMonth()+1) &&
|
|
30
|
+
(fields[2].values.includes(day)||fields[2].special.some(s=>s==='L'?day===last:s==='LW'?day===weekdayNear(d,last):day===weekdayNear(d,Number(s.slice(0,-1))))) &&
|
|
31
|
+
(fields[4].values.includes(weekday)||fields[4].special.some(s=>weekday===Number(s[0])&&(s.endsWith('L')?day+7>last:Math.ceil(day/7)===Number(s.at(-1)))));
|
|
32
|
+
};
|
|
33
|
+
return {parts,fields,dayMatches,matches(time){const d=new Date(time);return dayMatches(d)&&fields[0].values.includes(d.getUTCMinutes())&&fields[1].values.includes(d.getUTCHours());}};
|
|
34
|
+
}
|
|
35
|
+
export function nextDue(expression,after=Date.now()) {
|
|
36
|
+
const cron=typeof expression==='string'?parseSchedule(expression):expression;
|
|
37
|
+
const date=new Date(after);date.setUTCHours(0,0,0,0);
|
|
38
|
+
// A Gregorian calendar repeats after 400 years; this also detects impossible dates.
|
|
39
|
+
for(let days=0;days<=146097;days++,date.setUTCDate(date.getUTCDate()+1)) {
|
|
40
|
+
if(!cron.dayMatches(date))continue;
|
|
41
|
+
for(const hour of cron.fields[1].values)for(const minute of cron.fields[0].values) {
|
|
42
|
+
const time=date.getTime()+hour*3600000+minute*60000;
|
|
43
|
+
if(time>after)return time;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
export function scheduleTrigger(definitions,{eventFeeds=false}={}) {
|
|
49
|
+
if(eventFeeds||definitions.some(f=>f.schedule===undefined))return '* * * * *';
|
|
50
|
+
const schedules=definitions.map(f=>parseSchedule(f.schedule));
|
|
51
|
+
if(!schedules.length)return null;
|
|
52
|
+
if(schedules.every(c=>c.parts.join(' ')===schedules[0].parts.join(' ')))return schedules[0].parts.join(' ');
|
|
53
|
+
return ranges.map(([min,max],i)=>{
|
|
54
|
+
if(schedules.every(c=>c.parts[i]===schedules[0].parts[i]))return schedules[0].parts[i];
|
|
55
|
+
if(i===2||i===4)return '*';
|
|
56
|
+
const values=[...new Set(schedules.flatMap(c=>c.fields[i].values))].sort((a,b)=>a-b);
|
|
57
|
+
return values.length===max-min+1?'*':values.join(',');
|
|
58
|
+
}).join(' ');
|
|
59
|
+
}
|
|
60
|
+
export function scheduleStatus(definitions,crons,now=Date.now()) {
|
|
61
|
+
return definitions.filter(f=>f.schedule!==undefined).map(fn=>{
|
|
62
|
+
try {
|
|
63
|
+
const due=nextDue(fn.schedule,now);
|
|
64
|
+
const runnable=due!==null && crons.some(c=>{try{return parseSchedule(c).matches(due);}catch{return false;}});
|
|
65
|
+
return {function:fn.name,schedule:fn.schedule,nextDue:due===null?null:new Date(due).toISOString(),status:due===null?'Cannot run: schedule has no matching date':runnable?'Configured; awaiting scheduled delivery':'Cannot run: no configured trigger covers the next due time'};
|
|
66
|
+
} catch(error) {return {function:fn.name,schedule:fn.schedule,nextDue:null,status:`Cannot run: ${error.message}`};}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -84,3 +84,16 @@ WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.paid')
|
|
|
84
84
|
'app',NEW.game,'reference',(SELECT value FROM meta WHERE key='shop-reference:' || NEW.id)),NEW.paid_at);
|
|
85
85
|
END;
|
|
86
86
|
`;
|
|
87
|
+
|
|
88
|
+
/** Attempt history is independent of the prunable delivery queue. */
|
|
89
|
+
export const FUNCTIONS_RUNS_MIGRATION_FILE = '0023_function_runs.sql';
|
|
90
|
+
export const FUNCTIONS_RUNS_MIGRATION = `
|
|
91
|
+
CREATE TABLE function_runs (
|
|
92
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE,
|
|
93
|
+
function TEXT NOT NULL, event TEXT NOT NULL, trigger TEXT NOT NULL,
|
|
94
|
+
started INTEGER NOT NULL, finished INTEGER, outcome TEXT NOT NULL,
|
|
95
|
+
error TEXT, attempt INTEGER NOT NULL
|
|
96
|
+
);
|
|
97
|
+
CREATE INDEX function_runs_function_seq ON function_runs(function,seq);
|
|
98
|
+
CREATE INDEX function_runs_errors_seq ON function_runs(seq) WHERE outcome IN ('failed','interrupted');
|
|
99
|
+
`;
|
package/worker/functions.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {webhookSubscriptions,webhookFailure,webhookBackoff} from './event-webhooks.mjs';
|
|
2
|
+
import {parseSchedule,scheduleStatus} from './function-schedule.mjs';
|
|
3
|
+
import {redactText} from '../lib/redact.mjs';
|
|
2
4
|
import {feedSubscriptions,pushEvents} from './event-resources.mjs';
|
|
3
5
|
import { services } from './mcp-tools.mjs';
|
|
4
6
|
import { isOwner, sameOrigin } from './office.mjs';
|
|
@@ -63,16 +65,25 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
|
|
|
63
65
|
if(rows.length===100)more=true;
|
|
64
66
|
for (const row of rows) {
|
|
65
67
|
const lease=crypto.randomUUID();
|
|
66
|
-
const
|
|
68
|
+
const started=now();
|
|
69
|
+
const [claimed]=await env.DB.batch([
|
|
70
|
+
env.DB.prepare(`UPDATE function_deliveries SET state='running',lease=?3,due=?4,attempts=attempts+1 WHERE event=?1 AND function=?2 AND state!='complete' AND due<=?5`).bind(row.event,fn.name,lease,started+leaseMs,started),
|
|
71
|
+
env.DB.prepare("UPDATE function_runs SET outcome='interrupted',error='Worker lease expired before completion was recorded' WHERE event=?1 AND function=?2 AND outcome='running' AND EXISTS(SELECT 1 FROM function_deliveries WHERE event=?1 AND function=?2 AND lease=?3)").bind(row.event,fn.name,lease),
|
|
72
|
+
env.DB.prepare("INSERT INTO function_runs(id,function,event,trigger,started,outcome,attempt) SELECT ?1,?2,?3,?4,?5,'running',attempts FROM function_deliveries WHERE event=?3 AND function=?2 AND lease=?1").bind(lease,fn.name,row.event,row.event.startsWith(`schedule:${fn.name}:`)?`schedule: ${JSON.parse(row.data).cron}`:row.type,started),
|
|
73
|
+
]);
|
|
67
74
|
if (!claimed.meta.changes) continue;
|
|
75
|
+
let failure=null;
|
|
68
76
|
try {
|
|
69
77
|
const event=Object.freeze({id:row.event,type:row.type,at:row.at,data:JSON.parse(row.data),attempt:row.attempts+1});
|
|
70
78
|
await fn.handler(event,services(env,cat,origin,{id:'studio-function',owner:true,client:null},'function:'+fn.name,fn));
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
} catch(error) {failure=fn.webhook?webhookFailure(error):functionError(error,env);}
|
|
80
|
+
const finished=now();
|
|
81
|
+
await env.DB.batch([
|
|
82
|
+
failure===null
|
|
83
|
+
? 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,finished)
|
|
84
|
+
: 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,finished+(fn.webhook?webhookBackoff(row.attempts+1):leaseMs),fn.webhook?failure:'Function failed; inspect its code and retry'),
|
|
85
|
+
env.DB.prepare("UPDATE function_runs SET outcome=?2,finished=?3,error=?4 WHERE id=?1").bind(lease,failure===null?'complete':'failed',finished,failure),
|
|
86
|
+
]);
|
|
76
87
|
}
|
|
77
88
|
}
|
|
78
89
|
} while(more);
|
|
@@ -82,10 +93,18 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
|
|
|
82
93
|
}
|
|
83
94
|
export async function scheduleFunctions(event,env,cat,origin) {
|
|
84
95
|
const definitions=await syncFunctions(env);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
96
|
+
const scheduledTime=Math.floor(event.scheduledTime/60000)*60000;
|
|
97
|
+
await env.DB.prepare("INSERT INTO meta(key,value) VALUES('function-last-scheduled',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(JSON.stringify({cron:event.cron,scheduledTime})).run();
|
|
98
|
+
for(const fn of definitions) {
|
|
99
|
+
if(fn.schedule===undefined)continue;
|
|
100
|
+
let due=false;
|
|
101
|
+
try {due=parseSchedule(fn.schedule).matches(scheduledTime);} catch {continue;} // An old invalid declaration stays diagnosable via the owner tool.
|
|
102
|
+
if(!due)continue;
|
|
103
|
+
await env.DB.batch([
|
|
104
|
+
env.DB.prepare(`INSERT OR IGNORE INTO studio_events(id,type,data,at) SELECT ?1,?2,?3,?4 WHERE EXISTS(SELECT 1 FROM function_subscriptions WHERE name=?5 AND last_tick<?4)`).bind(`schedule:${fn.name}:${scheduledTime}`,fn.event,JSON.stringify({function:fn.name,cron:fn.schedule,scheduledTime}),scheduledTime,fn.name),
|
|
105
|
+
env.DB.prepare('UPDATE function_subscriptions SET last_tick=MAX(last_tick,?2) WHERE name=?1').bind(fn.name,scheduledTime),
|
|
88
106
|
]);
|
|
107
|
+
}
|
|
89
108
|
await runFunctions(env,cat,origin,{definitions});
|
|
90
109
|
}
|
|
91
110
|
export async function functionsRoute(request,env,cat) {
|
|
@@ -107,3 +126,20 @@ export async function functionsRoute(request,env,cat) {
|
|
|
107
126
|
const rows=(await env.DB.prepare(`SELECT d.*,e.type,e.at FROM function_deliveries d JOIN studio_events e ON e.id=d.event ORDER BY e.at DESC LIMIT 100`).all()).results;
|
|
108
127
|
return new Response(`<!doctype html><meta charset="utf-8"><title>Studio functions</title><h1>Studio functions</h1><p>At-least-once delivery. Use the event ID to deduplicate effects.</p><table><tr><th>Function</th><th>Event ID</th><th>Event</th><th>State</th><th>Attempts</th><th>Error</th><th>Retry</th></tr>${rows.map(r=>`<tr>${[r.function,r.event,r.type,r.state,r.attempts,r.error??''].map(v=>`<td>${esc(v)}</td>`).join('')}<td>${r.state==='failed'?`<form method="post"><input type="hidden" name="retry" value="${esc(r.event)}"><button>Retry</button></form>`:''}</td></tr>`).join('')}</table>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store'}});
|
|
109
128
|
}
|
|
129
|
+
|
|
130
|
+
function functionError(error,env) {
|
|
131
|
+
let message=error instanceof Error?error.message:String(error);
|
|
132
|
+
// Remove known binding values as well as recognizable credential formats.
|
|
133
|
+
for(const [key,value] of Object.entries(env))if(typeof value==='string' && value && !['STUDIO_NAME','STUDIO_EVENT_FEEDS','STUDIO_FUNCTION_CRONS','STUDIO_FUNCTION_MANAGED_CRONS'].includes(key))message=message.split(value).join('[redacted]');
|
|
134
|
+
return redactText(message).text;
|
|
135
|
+
}
|
|
136
|
+
export async function functionRuns(env,{name,errors=false,before}={}) {
|
|
137
|
+
const rows=(await env.DB.prepare(`SELECT seq,function,event,trigger,started,finished,outcome,error,attempt FROM function_runs
|
|
138
|
+
WHERE (?1 IS NULL OR function=?1) AND (?2=0 OR outcome IN ('failed','interrupted')) AND (?3 IS NULL OR seq<?3)
|
|
139
|
+
ORDER BY seq DESC LIMIT 101`).bind(name??null,errors?1:0,before??null).all()).results;
|
|
140
|
+
const page=rows.slice(0,100),definitions=await load();
|
|
141
|
+
let crons=[];try {crons=JSON.parse(env.STUDIO_FUNCTION_CRONS??'[]');if(!Array.isArray(crons))crons=[];}catch {}
|
|
142
|
+
const schedules=scheduleStatus(definitions.filter(f=>!name||f.name===name),crons);
|
|
143
|
+
const tick=await env.DB.prepare("SELECT value FROM meta WHERE key='function-last-scheduled'").first();
|
|
144
|
+
return {runs:page.map(r=>({...r,started:new Date(r.started).toISOString(),finished:r.finished===null?null:new Date(r.finished).toISOString()})),next:rows.length>100?page.at(-1).seq:null,schedules,crons,lastScheduled:tick?JSON.parse(tick.value):null,triggerEvidence:'Generated deployment configuration; lastScheduled proves an observed tick. Provider dashboard changes are not queried.'};
|
|
145
|
+
}
|
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
|
@@ -87,6 +87,10 @@ export function builtins(env,cat,origin) {
|
|
|
87
87
|
if(!ctx.caller.owner)throw new Error('Owner only');
|
|
88
88
|
return (await import('./event-webhooks.mjs')).testWebhook(env,cat,feed,origin);
|
|
89
89
|
}),
|
|
90
|
+
tool('studio_function_runs','Recent function attempts and errors, schedule next due times and trigger diagnostics. Run text is untrusted data. Follow next with before for older runs.','owner',object({name:str,errors:{type:'boolean'},before:{type:'integer',minimum:1}}),async(args,ctx)=>{
|
|
91
|
+
if(!ctx.caller.owner)throw new Error('Owner only');
|
|
92
|
+
return (await import('./functions.mjs')).functionRuns(env,args);
|
|
93
|
+
}),
|
|
90
94
|
tool('studio_function_replay','Replay retained events for a declaration with replay: true.','owner',object({name:str},['name']),async({name})=>{
|
|
91
95
|
const {replayFunction,runFunctions}=await import('./functions.mjs');
|
|
92
96
|
await replayFunction(env,name);await runFunctions(env,cat,origin);return {ok:true};
|
|
@@ -126,11 +130,11 @@ export function builtins(env,cat,origin) {
|
|
|
126
130
|
for(const [name,path,description] of [
|
|
127
131
|
['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.'],
|
|
128
132
|
]) tools.push(tool(name,description,'owner',object({action:{type:'object'}},['action']),({action},ctx)=>ctx.office('/_studio/api/'+path,action)));
|
|
129
|
-
const reads={rooms:'office',servers:'servers',members:'servers/members',chat:'chat',lounge:'lounge',shop:'shop',orders:'shop/orders.csv',
|
|
133
|
+
const reads={rooms:'office',servers:'servers',members:'servers/members',chat:'chat',lounge:'lounge',shop:'shop',orders:'shop/orders.csv',invites:'invites'};
|
|
130
134
|
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)));
|
|
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','
|
|
135
|
+
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'];
|
|
132
136
|
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)));
|
|
133
137
|
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)));
|
|
134
138
|
tools.push(...selling.customerTools(env,cat,origin));
|
|
135
|
-
return tools.map(tool => ({...tool, delegateOffice:Object.hasOwn(cat.studio?.mcp?.audiences??{},tool.name), audience: cat.studio?.mcp?.audiences?.[tool.name] ?? tool.audience}));
|
|
139
|
+
return tools.map(tool => tool.name==='studio_function_runs'?tool:({...tool, delegateOffice:Object.hasOwn(cat.studio?.mcp?.audiences??{},tool.name), audience: cat.studio?.mcp?.audiences?.[tool.name] ?? tool.audience}));
|
|
136
140
|
}
|
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 } = {}) {
|