@homie-rocks/studio 0.24.2 → 0.24.4
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 +56 -1
- package/README.md +51 -5
- package/agents/GUIDES.md +13 -6
- package/bin/homie-studio.mjs +77 -15
- package/chat/CHAT.md +2 -0
- package/lib/agent-seat.mjs +60 -2
- package/lib/brain-probe.mjs +6 -5
- package/lib/build.mjs +3 -0
- package/lib/cloudflare.mjs +13 -3
- package/lib/doctor.mjs +64 -5
- package/lib/local-ai.mjs +66 -0
- package/lib/mcp-tools.mjs +10 -2
- package/lib/projects-env.mjs +68 -0
- package/lib/projects.mjs +311 -0
- package/lib/scaffold.mjs +20 -2
- package/lib/servers.mjs +31 -2
- package/lib/shop-catalog.mjs +163 -0
- package/lib/shop.mjs +151 -60
- package/lib/template-history.json +78 -0
- package/netplay/NETPLAY.md +86 -12
- package/netplay/netplay.ts +82 -0
- package/package.json +1 -1
- package/shop/SELLING.md +16 -0
- package/shop/SHOP.md +65 -16
- package/starters/ember-vale/game.json +1 -0
- package/starters/ember-vale/src/main.ts +80 -6
- package/worker/agents.mjs +93 -47
- package/worker/brain.mjs +356 -5
- package/worker/chat.mjs +13 -6
- package/worker/index.mjs +57 -2
- package/worker/office.mjs +65 -4
- package/worker/room.mjs +48 -3
- package/worker/shop-rules.mjs +17 -1
- package/worker/shop.mjs +32 -9
- package/worker/stripe.mjs +30 -0
- package/worker/version.mjs +1 -1
package/worker/chat.mjs
CHANGED
|
@@ -14,12 +14,15 @@
|
|
|
14
14
|
* links, swears, and the studio's own words. It always runs, and it needs nothing.
|
|
15
15
|
* review typed text the floor let through, on the studio's own Workers AI: Cloudflare's Clef decision model
|
|
16
16
|
* (`@cf/cloudflare/clef-flash`, launched 2026-10-01: a probability for each answer, about 40 ms), within a
|
|
17
|
-
* day's budget of neurons.
|
|
17
|
+
* day's budget of neurons. Under `homie-studio dev` with no binding, the same questions go to the person's
|
|
18
|
+
* own Ollama (clef-flash) when dev found it there: free. No model, no budget left or an error: the floor
|
|
19
|
+
* alone decides.
|
|
18
20
|
*
|
|
19
21
|
* Emoji and quick lines are the studio's own words: they never need a review, so they reach every screen at once.
|
|
20
22
|
* NOTHING is stored here: the relay keeps a short window in memory (section 19), and a report keeps only the message.
|
|
21
23
|
*/
|
|
22
24
|
import { WORDS_B64 } from './chat-words.mjs';
|
|
25
|
+
import { clefRun, localAiOf } from './brain.mjs';
|
|
23
26
|
|
|
24
27
|
export const CHAT_MODES = Object.freeze(['off', 'emoji', 'lines', 'text']);
|
|
25
28
|
export const CHAT_WHO = Object.freeze(['anyone', 'signed-in', 'members']);
|
|
@@ -387,9 +390,11 @@ const timeout = (p, ms) => Promise.race([p, new Promise((_, no) => setTimeout(()
|
|
|
387
390
|
* (`why` is the most likely other answer). Throws when there is no binding, the model fails or it takes too long:
|
|
388
391
|
* the caller lets the floor decide.
|
|
389
392
|
*/
|
|
390
|
-
export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || CHAT_MODEL, ms = CHAT_LIMITS.reviewMs, threshold = REVIEW_THRESHOLD, links = 'block', now = () => Date.now() } = {}) {
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
+
export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || CHAT_MODEL, ms = CHAT_LIMITS.reviewMs, threshold = REVIEW_THRESHOLD, links = 'block', now = () => Date.now(), fetch: fetchImpl = null } = {}) {
|
|
394
|
+
// No binding: the person's own Ollama (`homie-studio dev` found clef-flash there), with the same question. Free.
|
|
395
|
+
const local = env?.AI && typeof env.AI.run === 'function' ? null : localAiOf(env);
|
|
396
|
+
if (!local && (!env?.AI || typeof env.AI.run !== 'function')) throw new Error('this Worker has no Workers AI binding (AI)');
|
|
397
|
+
const spec = local ? { kind: 'clef', name: local.model, input: 0, output: 0 } : CHAT_MODELS[model] ?? CHAT_MODELS[CHAT_MODEL];
|
|
393
398
|
const at = now();
|
|
394
399
|
if (spec.kind === 'guard') {
|
|
395
400
|
const r = await timeout(env.AI.run(model, { messages: [{ role: 'user', content: text }], temperature: 0, max_tokens: 16, response_format: { type: 'json_object' } }), ms);
|
|
@@ -402,7 +407,9 @@ export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || C
|
|
|
402
407
|
}
|
|
403
408
|
// A room that allows links says so, so a plain link is not read as spam.
|
|
404
409
|
const verdict = links === 'allow' ? { ...VERDICT, criteria: { ...VERDICT.criteria, spam: 'Ads, scams or spam (a plain link is fine here)' } } : VERDICT;
|
|
405
|
-
const r =
|
|
410
|
+
const r = local
|
|
411
|
+
? await clefRun(env, { state: text, questions: { verdict }, ms: Math.max(ms, 4000), local, fetch: fetchImpl })
|
|
412
|
+
: await timeout(env.AI.run(model, { model: spec.name, state: text, questions: { verdict } }), ms);
|
|
406
413
|
const a = r?.answers?.verdict;
|
|
407
414
|
if (!a || typeof a !== 'object') throw new Error('the review answered nothing it could use');
|
|
408
415
|
const probs = a.probabilities && typeof a.probabilities === 'object' ? a.probabilities : null;
|
|
@@ -415,5 +422,5 @@ export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || C
|
|
|
415
422
|
why = why ?? 'held';
|
|
416
423
|
}
|
|
417
424
|
const input = Number(r?.usage?.input_tokens ?? r?.usage?.prompt_tokens) || Math.ceil((text.length + VERDICT_CHARS) / 4);
|
|
418
|
-
return { ok: pOk >= threshold, why, p: Math.round(pOk * 1000) / 1000, neurons: (input * spec.input) / 1e6, ms: now() - at, model };
|
|
425
|
+
return { ok: pOk >= threshold, why, p: Math.round(pOk * 1000) / 1000, neurons: (input * spec.input) / 1e6, ms: now() - at, model: local ? local.model : model, ...(local ? { local: true } : {}) };
|
|
419
426
|
}
|
package/worker/index.mjs
CHANGED
|
@@ -86,7 +86,7 @@ import {
|
|
|
86
86
|
setMembership,
|
|
87
87
|
} from './servers.mjs';
|
|
88
88
|
import { BRAIN_BUDGET, HouseAgents, agentFacts, aiName, decodeFacts, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
|
|
89
|
-
import { talks } from './brain.mjs';
|
|
89
|
+
import { CLEF, DECIDE, clefRun, costOf, localAiOf, picksOf, talks } from './brain.mjs';
|
|
90
90
|
import { REACTIONS, reviewChat } from './chat.mjs';
|
|
91
91
|
import { REPORT_REASONS, chatDay, chatOf, chatRowsOf, fileReport } from './chat-store.mjs';
|
|
92
92
|
import { doorPage, serverPage, serversPage } from './site.mjs';
|
|
@@ -1163,16 +1163,71 @@ export class Table {
|
|
|
1163
1163
|
this.vocabRead = this.readVocab(game).catch(() => false);
|
|
1164
1164
|
// Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
|
|
1165
1165
|
this.room.review = (text, opts) => this.reviewChat(text, opts);
|
|
1166
|
+
// A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
|
|
1167
|
+
this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
|
|
1166
1168
|
return this.room;
|
|
1167
1169
|
}
|
|
1168
1170
|
|
|
1171
|
+
/** Whether this game asked for decisions (game.json "decide": true, in the built catalogue), read once a minute. */
|
|
1172
|
+
async decideOn() {
|
|
1173
|
+
if (this.decideRead && Date.now() - this.decideRead.at < 60_000) return this.decideRead.on;
|
|
1174
|
+
let on = false;
|
|
1175
|
+
try {
|
|
1176
|
+
const res = this.env.ASSETS ? await this.env.ASSETS.fetch(new Request('https://assets.local/games.json')) : null;
|
|
1177
|
+
const cat = res?.ok ? await res.json() : null;
|
|
1178
|
+
const g = (cat?.games ?? []).find((x) => x.id === this.game);
|
|
1179
|
+
on = Boolean(g && (g.decide === true || (g.decide && typeof g.decide === 'object' && g.decide.on !== false)));
|
|
1180
|
+
} catch { on = false; }
|
|
1181
|
+
this.decideRead = { at: Date.now(), on };
|
|
1182
|
+
return on;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* One of a game's own decisions (worker/brain.mjs checkDecide, picksOf): Clef on the studio's Workers AI (model
|
|
1187
|
+
* HOMIE_DECIDE_MODEL, else clef-flash), or the person's own Ollama under `homie-studio dev`, within the AI brains' day
|
|
1188
|
+
* (the same budget the guides spend: decisions and guides are one day of neurons). Not opted in, no AI, over budget, too
|
|
1189
|
+
* slow or failed: `{ ok: false, why }`, and the host's floor answers.
|
|
1190
|
+
*/
|
|
1191
|
+
async decide(state, questions) {
|
|
1192
|
+
if (!(await this.decideOn())) return { ok: false, why: 'off' };
|
|
1193
|
+
const local = this.env.AI ? null : localAiOf(this.env);
|
|
1194
|
+
if (!this.env.AI && !local) return { ok: false, why: 'no-ai' };
|
|
1195
|
+
if (this.env.AI) {
|
|
1196
|
+
if (!this.brainDay || Date.now() - this.brainReadAt > 60_000) await this.readBrainDay().catch(() => {});
|
|
1197
|
+
if (this.brainCap.neurons - this.brainUsed.neurons <= 0) return { ok: false, why: 'budget' };
|
|
1198
|
+
}
|
|
1199
|
+
const model = CLEF.models[this.env.HOMIE_DECIDE_MODEL] ? this.env.HOMIE_DECIDE_MODEL : '@cf/cloudflare/clef-flash';
|
|
1200
|
+
const t0 = Date.now();
|
|
1201
|
+
try {
|
|
1202
|
+
const r = await clefRun(this.env, { state, questions, model, ms: DECIDE.ms, local });
|
|
1203
|
+
const ms = Date.now() - t0;
|
|
1204
|
+
const usage = r.usage ?? { input_tokens: Math.ceil(JSON.stringify({ state, questions }).length / 4) + 400, output_tokens: 0 };
|
|
1205
|
+
const cost = costOf({ provider: r.engine === 'local' ? 'local' : 'workers-ai', model: r.model, usage });
|
|
1206
|
+
this.brainSpend({ neurons: cost.neurons, micros: 0, provider: r.engine === 'local' ? 'decide-local' : 'decide' });
|
|
1207
|
+
this.flushSoon();
|
|
1208
|
+
const { picks, p } = picksOf(r.answers, questions);
|
|
1209
|
+
return { ok: true, by: r.engine === 'local' ? 'local' : 'ai', picks, p, ms, neurons: cost.neurons };
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
return { ok: false, why: /took over/.test(String(error?.message ?? error)) ? 'slow' : 'error' };
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** The day's counters go to D1 on an alarm: set one a minute out when none is set (a house guide's alarm also flushes). */
|
|
1216
|
+
flushSoon() {
|
|
1217
|
+
this.ctx.waitUntil((async () => { if ((await this.ctx.storage.getAlarm()) === null) await this.ctx.storage.setAlarm(Date.now() + 60_000); })().catch(() => {}));
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1169
1220
|
/**
|
|
1170
1221
|
* One typed chat message, reviewed by the studio's decision model (worker/chat.mjs reviewChat) within the day's
|
|
1171
1222
|
* budget for the whole studio. No binding (`homie-studio dev` without --remote-ai), no budget left, or an error: the
|
|
1172
1223
|
* floor alone has decided (`by` says which), and the message goes out.
|
|
1173
1224
|
*/
|
|
1174
1225
|
async reviewChat(text, { links = 'block' } = {}) {
|
|
1175
|
-
|
|
1226
|
+
// Under `homie-studio dev` with no binding, the person's own Ollama reviews (free, so no budget).
|
|
1227
|
+
if (!this.env.AI) {
|
|
1228
|
+
if (!localAiOf(this.env)) return { ok: true, by: 'none' };
|
|
1229
|
+
try { const v = await reviewChat(this.env, text, { links }); return { ok: v.ok, by: 'local', why: v.why, ms: v.ms, p: v.p }; } catch { return { ok: true, by: 'error' }; }
|
|
1230
|
+
}
|
|
1176
1231
|
if (this.chatCap === null || Date.now() - this.chatReadAt > 60_000) await this.readChatDay().catch(() => {});
|
|
1177
1232
|
if (this.chatUsed >= (this.chatCap ?? 0)) return { ok: true, by: 'budget' };
|
|
1178
1233
|
try {
|
package/worker/office.mjs
CHANGED
|
@@ -56,9 +56,9 @@
|
|
|
56
56
|
*/
|
|
57
57
|
import { SEAT_MAX, seatsOf } from './seats.mjs';
|
|
58
58
|
import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
|
|
59
|
-
import { BRAIN_BUDGET, aiName, fillSpot, passById, passCreate, passList, passRevoke } from './agents.mjs';
|
|
60
|
-
import { DEFAULT_MODEL, OWNER_MODEL } from './brain.mjs';
|
|
61
|
-
import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession, today } from './stats.mjs';
|
|
59
|
+
import { BRAIN_BUDGET, aiName, brainFor, fillSpot, passById, passCreate, passList, passRevoke, think } from './agents.mjs';
|
|
60
|
+
import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain.mjs';
|
|
61
|
+
import { OWNER_COOKIE, cookieValues, counter, ownerAllowed, ownerSession, today } from './stats.mjs';
|
|
62
62
|
import { esc, layout, notFoundPage } from './site.mjs';
|
|
63
63
|
import { confirmPage, lockedPage, officePage } from './office-page.mjs';
|
|
64
64
|
import { licenseOf } from './license.mjs';
|
|
@@ -473,6 +473,8 @@ async function roomRow(env, meta, room, max) {
|
|
|
473
473
|
clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
|
|
474
474
|
// Room chat (0.23.0): the room's last minutes, with who sent each line (a client id and account; never an address).
|
|
475
475
|
chat: office.chat ? { mode: office.chat.rules?.mode ?? null, pending: office.chat.pending ?? 0, lines: (office.chat.lines ?? []).slice(-50).map((l) => ({ id: l.id, at: l.at, t: l.t, kind: l.kind, name: oneLine(l.name, 40), seat: l.seat ?? null, by: l.by, text: l.text ?? null, glyph: l.glyph ?? null, react: l.react ?? null, say: l.say ?? null, acct: Boolean(l.acct), owner: Boolean(l.owner), client: l.client ?? null, player: l.player ?? null, browser: l.browser ?? null, ...(l.review ? { review: l.review } : {}) })) } : null,
|
|
476
|
+
// A game's own decisions (0.24.4, net.decide): how many, who answered, the model's median time, the neurons.
|
|
477
|
+
...(office.decides && typeof office.decides === 'object' ? { decides: { n: Number(office.decides.n) || 0, by: office.decides.by ?? {}, msP50: office.decides.msP50 ?? null, neurons: Number(office.decides.neurons) || 0 } } : {}),
|
|
476
478
|
// The house guides' brains (0.17.0): which brain, why not when it is not, and the last decisions (seats and ids).
|
|
477
479
|
...(f.brains && typeof f.brains === 'object' ? { brains: {
|
|
478
480
|
brain: String(f.brains.brain ?? ''), why: f.brains.why ? oneLine(f.brains.why, 120) : null, calls: Number(f.brains.calls) || 0,
|
|
@@ -482,6 +484,62 @@ async function roomRow(env, meta, room, max) {
|
|
|
482
484
|
};
|
|
483
485
|
}
|
|
484
486
|
|
|
487
|
+
/**
|
|
488
|
+
* `agents try` (0.24.4): a guide's brain on one moment the owner gives, with no seat taken and nobody in a room: the
|
|
489
|
+
* game's own vocabulary, the view, the asks and the party's lines in; the decision out, with the brain's own pick
|
|
490
|
+
* before the fixed rules, Clef's probabilities, the model's time and the neurons. It runs exactly what a house guide
|
|
491
|
+
* runs (agents.mjs think) and spends from the same day's budget, so a creator can test an agents.json, and compare a
|
|
492
|
+
* model (`model`: one of the models this toolkit prices) before setting it.
|
|
493
|
+
*/
|
|
494
|
+
export async function agentsTry(env, cat, body) {
|
|
495
|
+
const bad = (message) => ({ ok: false, error: 'bad-request', message });
|
|
496
|
+
const meta = (cat.games ?? []).find((g) => g.id === body.game);
|
|
497
|
+
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
498
|
+
const mode = body.mode ?? 'workers-ai';
|
|
499
|
+
if (!['workers-ai', 'owner-key', 'script'].includes(mode)) return bad('mode is workers-ai, owner-key or script');
|
|
500
|
+
if (body.model !== undefined && !(typeof body.model === 'string' && NEURONS_PER_M[body.model])) return bad(`model is one of ${Object.keys(NEURONS_PER_M).join(', ')}`);
|
|
501
|
+
const view = body.view && typeof body.view === 'object' && !Array.isArray(body.view) ? body.view : null;
|
|
502
|
+
if (!view || JSON.stringify(view).length > 2048) return bad('view is the game state a guide sees (an object, under 2 KB)');
|
|
503
|
+
const int = (v) => Number.isInteger(v) && v >= 0 && v < 64;
|
|
504
|
+
const asks = (Array.isArray(body.asks) ? body.asks : []).slice(0, 3).filter((a) => a && typeof a.k === 'string' && int(a.from)).map((a, i) => ({ k: a.k, args: a.args && typeof a.args === 'object' ? a.args : {}, from: a.from, at: Date.now() - i }));
|
|
505
|
+
const party = (Array.isArray(body.party) ? body.party : []).slice(-3).filter((p) => p && int(p.seat) && typeof p.line === 'string').map((p) => ({ seat: p.seat, line: p.line, args: p.args ?? {} }));
|
|
506
|
+
const seats = (list) => (Array.isArray(list) ? list.filter(int).slice(0, 32) : []);
|
|
507
|
+
let vocab = null;
|
|
508
|
+
try {
|
|
509
|
+
const res = env.ASSETS ? await env.ASSETS.fetch(new Request(`https://assets.local/games/${meta.id}/agents.json`)) : null;
|
|
510
|
+
if (res?.ok) vocab = vocabularyOf(await res.json()).vocab;
|
|
511
|
+
} catch { vocab = null; }
|
|
512
|
+
if (!vocab) return { ok: false, error: 'no-vocabulary', message: `${meta.name} has no agents.json in this build: its guides have nothing to choose from.` };
|
|
513
|
+
const day = await brainDay(env);
|
|
514
|
+
const budget = { left: (k) => (k === 'neurons' ? day.budget.neurons - day.used.neurons : day.budget.usd * 1e6 - day.used.usd * 1e6) };
|
|
515
|
+
const e = body.model ? { ...env, HOMIE_BRAIN_MODEL: body.model } : env;
|
|
516
|
+
const brain = brainFor(mode, e, { budget });
|
|
517
|
+
const ctx = {
|
|
518
|
+
view, me: { seat: int(body.seat) ? body.seat : 7 }, goal: view.goal ?? null, asks, party, speech: ['game', 'lines', 'off'].includes(body.speech) ? body.speech : 'lines',
|
|
519
|
+
kids: body.kids === true, quiet: body.quiet === true, players: seats(body.players ?? (Array.isArray(view.party) ? view.party.map((p) => p?.seat) : [])), avoid: seats(body.avoid),
|
|
520
|
+
// What a house guide remembers: who is new to it, and its own last lines (ids, arguments, how long ago).
|
|
521
|
+
newHere: seats(body.newHere),
|
|
522
|
+
said: (Array.isArray(body.said) ? body.said : []).slice(-4).filter((x) => x && typeof x.line === 'string' && vocab.lines[x.line]).map((x) => ({ line: x.line, args: x.args && typeof x.args === 'object' ? x.args : {}, ago: Math.max(0, Number(x.agoMs) || 0) })),
|
|
523
|
+
};
|
|
524
|
+
const t = await think(vocab, { mode, brain, ctx });
|
|
525
|
+
if (t.engine && env.DB) {
|
|
526
|
+
const rows = [counter(env, { metric: 'brain-calls', subject: meta.id, source: 'try' })];
|
|
527
|
+
const n = Math.round(t.cost.neurons);
|
|
528
|
+
if (n > 0) rows.push(counter(env, { metric: 'brain-neurons', subject: meta.id, n }));
|
|
529
|
+
if (t.cost.micros >= 1) rows.push(counter(env, { metric: 'brain-microdollars', subject: meta.id, n: Math.round(t.cost.micros) }));
|
|
530
|
+
await env.DB.batch(rows.filter(Boolean)).catch(() => {});
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
ok: true, game: meta.id, mode, model: t.model ?? (mode === 'workers-ai' ? e.HOMIE_BRAIN_MODEL || DEFAULT_MODEL : null), engine: t.engine, provider: t.provider,
|
|
534
|
+
decision: t.decision, own: t.own, overruled: /\+floor$/.test(t.provider), why: t.why, p: t.p, ms: t.ms,
|
|
535
|
+
// A decision model's whole answer: every option's probability, per question (ids and numbers only).
|
|
536
|
+
...(t.answers ? { answers: Object.fromEntries(Object.entries(t.answers).map(([k, a]) => [k, a?.probabilities ? Object.fromEntries(Object.entries(a.probabilities).map(([o, v]) => [o, Math.round(Number(v) * 100) / 100])) : a?.noul ?? a?.score ?? null])) } : {}),
|
|
537
|
+
...(t.usage ? { tokens: { input: Number(t.usage.input_tokens ?? t.usage.prompt_tokens) || null, output: Number(t.usage.output_tokens ?? t.usage.completion_tokens) || 0 } } : {}),
|
|
538
|
+
neurons: Math.round(t.cost.neurons * 1000) / 1000, micros: Math.round(t.cost.micros),
|
|
539
|
+
budget: { neurons: day.budget.neurons, usedBefore: Math.round(day.used.neurons) },
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
485
543
|
/** The AI brains' day for the whole studio: the budget (meta brain_budget) and what today used (stats_daily). */
|
|
486
544
|
export async function brainDay(env) {
|
|
487
545
|
const budget = { neurons: BRAIN_BUDGET.neurons, usd: BRAIN_BUDGET.usd };
|
|
@@ -1163,6 +1221,8 @@ async function api(request, env, url, cat) {
|
|
|
1163
1221
|
return json({ ok: true, game, server, members });
|
|
1164
1222
|
} catch (error) { return json({ ok: false, error: 'not-migrated', message: `Servers need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503); }
|
|
1165
1223
|
}
|
|
1224
|
+
// A guide's brain on a moment the owner gives (0.24.4): what it would decide, why, how sure, how long and what it cost.
|
|
1225
|
+
if (path === '/_studio/api/agents/try' && request.method === 'POST') return json(await agentsTry(env, cat, body));
|
|
1166
1226
|
// Room chat (0.23.0): every game's rules, every live room's last minutes, the reports, the review's day.
|
|
1167
1227
|
if (path === '/_studio/api/chat' && request.method === 'GET') {
|
|
1168
1228
|
const metas = (cat.games ?? []).filter((g) => !url.searchParams.get('game') || g.id === url.searchParams.get('game'));
|
|
@@ -1213,7 +1273,8 @@ async function api(request, env, url, cat) {
|
|
|
1213
1273
|
}
|
|
1214
1274
|
}
|
|
1215
1275
|
const result = await perform(env, cat, action);
|
|
1216
|
-
|
|
1276
|
+
// A refund Stripe holds for a person's approval (an Agent-tagged key) is accepted, not refused: it happens there.
|
|
1277
|
+
return json({ ...result, what: describe(cat, action), by: who }, result.ok ? 200 : result.held ? 202 : result.error === 'no-player' ? 404 : 400);
|
|
1217
1278
|
}
|
|
1218
1279
|
|
|
1219
1280
|
/** /_studio/office, /_studio/confirm/<ask> and /_studio/api/*; null for any other path. */
|
package/worker/room.mjs
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
* =============================================================================
|
|
45
45
|
*/
|
|
46
46
|
import { aiName, skillPreset, stripAi } from './agents.mjs';
|
|
47
|
-
import { checkArgs, talks, vocabularyOf } from './brain.mjs';
|
|
47
|
+
import { DECIDE, checkArgs, checkDecide, talks, vocabularyOf } from './brain.mjs';
|
|
48
48
|
import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normalizeChat, publicChat } from './chat.mjs';
|
|
49
49
|
|
|
50
50
|
export const NET_VERSION = 1;
|
|
@@ -103,6 +103,8 @@ export const LIMITS = Object.freeze({
|
|
|
103
103
|
hello: 2048, snap: 16384, in: 2048, ev: 4096, ckpt: 65536, state: 8192, round: 8192, roster: 4096, ping: 256, other: 512,
|
|
104
104
|
// Room chat (revision 8): a typed line (280 characters, emoji count double), a reaction.
|
|
105
105
|
say: 1536, react: 256,
|
|
106
|
+
// A game's own decision (section 20): a few typed questions about its state.
|
|
107
|
+
decide: DECIDE.bytes,
|
|
106
108
|
});
|
|
107
109
|
/**
|
|
108
110
|
* What grows with the seats (every body, every result, every slot): the checkpoint, round and roster caps double
|
|
@@ -112,7 +114,7 @@ export const LIMITS = Object.freeze({
|
|
|
112
114
|
export const GROWS = Object.freeze(['ckpt', 'round', 'roster']);
|
|
113
115
|
export const capOf = (t, seats) => (LIMITS[t] ?? LIMITS.other) * (GROWS.includes(t) && seats > 16 ? 2 : 1);
|
|
114
116
|
/** Rate caps (messages per rolling second, per client). Over the cap a message is dropped, counted and reported. */
|
|
115
|
-
export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8, say: 4, react: 10 });
|
|
117
|
+
export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8, say: 4, react: 10, decide: 2 });
|
|
116
118
|
/** `ev` per second by the sender's role: a screen is a spectator, and the host is somebody's phone. */
|
|
117
119
|
export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
|
|
118
120
|
/** The keyed state channel: at most this many keys and bytes per room. */
|
|
@@ -274,7 +276,7 @@ export class NetRoom {
|
|
|
274
276
|
this.stats = {
|
|
275
277
|
snaps: 0, ins: 0, evs: 0, ckpts: 0, states: 0, rounds: 0, drops: 0, oversize: 0, stFixed: 0, reclaimed: 0, seated: 0,
|
|
276
278
|
kicked: 0, refused: 0, persists: 0, restoredFrom: null, bytesIn: 0, bytesOut: 0, elections: [], promotions: 0,
|
|
277
|
-
speechDrops: 0, agentDrops: 0, labelled: 0, votes: 0,
|
|
279
|
+
speechDrops: 0, agentDrops: 0, labelled: 0, votes: 0, decides: 0, decided: {}, decideMs: [],
|
|
278
280
|
};
|
|
279
281
|
this.snapTimes = [];
|
|
280
282
|
/** The owner's controls (section 15): who is held out of this room until when, who is muted, the banner, a closed door. */
|
|
@@ -299,6 +301,13 @@ export class NetRoom {
|
|
|
299
301
|
this.chatReacts = [];
|
|
300
302
|
this.chatPending = 0;
|
|
301
303
|
this.review = null;
|
|
304
|
+
/**
|
|
305
|
+
* A game's own decisions (section 20): the host's typed questions, answered by the studio's decision model (the Table
|
|
306
|
+
* sets `decider`; null: the host's floor answers), at most one every 3 s and 20 a minute a room, two at a time.
|
|
307
|
+
*/
|
|
308
|
+
this.decider = null;
|
|
309
|
+
this.decideTimes = [];
|
|
310
|
+
this.decidePending = 0;
|
|
302
311
|
this.watchIds = new WeakMap();
|
|
303
312
|
this.defaultChat = null;
|
|
304
313
|
}
|
|
@@ -403,6 +412,39 @@ export class NetRoom {
|
|
|
403
412
|
peers() { return this.live().map((c) => this.peer(c)); }
|
|
404
413
|
stateObject() { const o = {}; for (const [k, v] of this.state) o[k] = v.d; return o; }
|
|
405
414
|
|
|
415
|
+
/**
|
|
416
|
+
* A game's own decision (section 20): the host's typed questions about its state, answered by the studio's decision
|
|
417
|
+
* model through the Table (`decider`). Only the host asks; the answer (`decided`, option ids and numbers only) goes
|
|
418
|
+
* back to that socket alone. Off, paced, over budget, too slow or wrong: `ok: false` with why, and the host's own floor
|
|
419
|
+
* answers. `n` is the host's own id for the request.
|
|
420
|
+
*/
|
|
421
|
+
onDecide(c, m, isHost) {
|
|
422
|
+
const n = typeof m.n === 'string' || Number.isInteger(m.n) ? String(m.n).slice(0, 16) : '';
|
|
423
|
+
const reply = (o) => this.send(c, { t: 'decided', n, ...o });
|
|
424
|
+
if (!isHost || this.lite(c)) return reply({ ok: false, why: 'not-host' });
|
|
425
|
+
if (typeof this.decider !== 'function') return reply({ ok: false, why: 'off' });
|
|
426
|
+
const now = this.now();
|
|
427
|
+
while (this.decideTimes.length && this.decideTimes[0] <= now - 60_000) this.decideTimes.shift();
|
|
428
|
+
const last = this.decideTimes.at(-1) ?? -Infinity;
|
|
429
|
+
if (now - last < DECIDE.gapMs - 100 || this.decideTimes.length >= DECIDE.perMinute) return reply({ ok: false, why: 'pace', retryMs: Math.max(DECIDE.gapMs - (now - last), this.decideTimes.length >= DECIDE.perMinute ? this.decideTimes[0] + 60_000 - now : 0) });
|
|
430
|
+
if (this.decidePending >= DECIDE.inFlight) return reply({ ok: false, why: 'busy' });
|
|
431
|
+
const q = checkDecide(m.state, m.questions);
|
|
432
|
+
if (!q.ok) return reply({ ok: false, why: 'bad', message: q.why });
|
|
433
|
+
this.decideTimes.push(now);
|
|
434
|
+
this.decidePending += 1;
|
|
435
|
+
this.stats.decides += 1;
|
|
436
|
+
Promise.resolve()
|
|
437
|
+
.then(() => this.decider(q.state, q.questions, { room: this.code, kids: this.policy.kids }))
|
|
438
|
+
.then((r) => {
|
|
439
|
+
const by = r?.ok ? r.by ?? 'ai' : r?.why ?? 'error';
|
|
440
|
+
this.stats.decided[by] = (this.stats.decided[by] ?? 0) + 1;
|
|
441
|
+
if (r?.ok) { this.stats.decideMs.push(r.ms ?? 0); if (this.stats.decideMs.length > 40) this.stats.decideMs.shift(); this.stats.decideNeurons = (this.stats.decideNeurons ?? 0) + (Number(r.neurons) || 0); }
|
|
442
|
+
reply(r?.ok ? { ok: true, by: r.by ?? 'ai', picks: r.picks ?? {}, p: r.p ?? {}, ms: r.ms ?? null } : { ok: false, why: r?.why ?? 'error' });
|
|
443
|
+
})
|
|
444
|
+
.catch(() => { this.stats.decided.error = (this.stats.decided.error ?? 0) + 1; reply({ ok: false, why: 'error' }); })
|
|
445
|
+
.finally(() => { this.decidePending -= 1; });
|
|
446
|
+
}
|
|
447
|
+
|
|
406
448
|
/** Drop the socket from the room and close it. */
|
|
407
449
|
kick(c, why, code = 1008) {
|
|
408
450
|
this.stats.kicked += 1;
|
|
@@ -515,6 +557,7 @@ export class NetRoom {
|
|
|
515
557
|
}
|
|
516
558
|
return;
|
|
517
559
|
}
|
|
560
|
+
case 'decide': return this.onDecide(c, m, isHost);
|
|
518
561
|
case 'ckpt': {
|
|
519
562
|
if (!isHost) return;
|
|
520
563
|
this.lastCkpt = { k: Number(m.k) || 0, st: this.stamp(m.st), d: m.d ?? null, ...(Array.isArray(m.c) ? { c: m.c.slice(0, 64) } : {}) };
|
|
@@ -1821,6 +1864,8 @@ export class NetRoom {
|
|
|
1821
1864
|
pending: this.chatPending,
|
|
1822
1865
|
lines: this.recentChat(now).map((r) => ({ ...this.wireLine(r), kind: r.kind, client: r.from?.client ?? null, player: r.from?.player ?? null, browser: browserTag(r.from?.browser), ...(r.review ? { review: r.review } : {}) })),
|
|
1823
1866
|
},
|
|
1867
|
+
// A game's own decisions (section 20): how many, who answered, the model's time and neurons (never a state).
|
|
1868
|
+
...(this.stats.decides ? { decides: { n: this.stats.decides, by: { ...this.stats.decided }, msP50: [...this.stats.decideMs].sort((x, y) => x - y)[Math.floor(this.stats.decideMs.length / 2)] ?? null, neurons: Math.round((this.stats.decideNeurons ?? 0) * 100) / 100 } } : {}),
|
|
1824
1869
|
},
|
|
1825
1870
|
};
|
|
1826
1871
|
}
|
package/worker/shop-rules.mjs
CHANGED
|
@@ -206,16 +206,32 @@ export function checkShop(raw, { games = null, audience = 'general' } = {}) {
|
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
// Where `homie-studio shop catalog` made the items' Products in Stripe (0.24.3): the Worker's checkouts then name them.
|
|
210
|
+
let catalog = [];
|
|
211
|
+
if (raw.catalog !== undefined && raw.catalog !== null) {
|
|
212
|
+
if (!Array.isArray(raw.catalog) || raw.catalog.some((m) => m !== 'test' && m !== 'live')) err('catalog', 'catalog lists where the items are Products in Stripe: ["test"], ["test", "live"] (homie-studio shop catalog writes it)');
|
|
213
|
+
else catalog = [...new Set(raw.catalog)].sort();
|
|
214
|
+
}
|
|
215
|
+
|
|
209
216
|
const kids = audience === 'kids';
|
|
210
217
|
const finalTill = kids ? 'off' : TILLS.includes(till) ? till : 'off';
|
|
211
218
|
if (kids && till !== 'off') warnings.push({ at: 'till', message: 'studio.json says "audience": "kids": the studio sells nothing in its games (the till is off whatever shop.json says).' });
|
|
212
219
|
const shop = {
|
|
213
220
|
v: 1, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
|
|
214
|
-
open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}),
|
|
221
|
+
open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}), ...(catalog.length ? { catalog } : {}),
|
|
215
222
|
};
|
|
216
223
|
return { ok: errors.length === 0, shop, errors, warnings };
|
|
217
224
|
}
|
|
218
225
|
|
|
226
|
+
/**
|
|
227
|
+
* The Stripe product tax code an item gets when shop.json names none: video games, downloaded, permanent rights
|
|
228
|
+
* (txcd_10201000), or limited rights for an item that lasts a number of days or ends on a date (txcd_10201001).
|
|
229
|
+
* Managed Payments needs one of its eligible codes on every product; these two are.
|
|
230
|
+
*/
|
|
231
|
+
export function defaultTaxCode(item) {
|
|
232
|
+
return item?.taxCode ?? (item?.days || item?.ends ? 'txcd_10201001' : 'txcd_10201000');
|
|
233
|
+
}
|
|
234
|
+
|
|
219
235
|
/** studio.json "audience": "general" (the default), "teens" or "kids". */
|
|
220
236
|
export function audienceOf(studio) {
|
|
221
237
|
const a = studio?.audience;
|
package/worker/shop.mjs
CHANGED
|
@@ -22,17 +22,23 @@
|
|
|
22
22
|
* player's account: it changes nothing until it is decided, and a lost one is a refund of that one item.
|
|
23
23
|
*
|
|
24
24
|
* SECRETS (Worker secrets, put there by `homie-studio shop connect` from a page on the owner's own computer):
|
|
25
|
-
* STRIPE_KEY (a restricted key: Checkout Sessions write, Charges write for refunds, PaymentIntents and Disputes read
|
|
26
|
-
*
|
|
25
|
+
* STRIPE_KEY (a restricted key: Checkout Sessions write, Charges write for refunds, PaymentIntents and Disputes read;
|
|
26
|
+
* from 0.24.3 also Webhook Endpoints write, which only the connect page on the owner's computer uses) and
|
|
27
|
+
* STRIPE_WEBHOOK_SECRET. Never in a file, a chat, a log or a page.
|
|
28
|
+
*
|
|
29
|
+
* THE CATALOG (0.24.3): once `homie-studio shop catalog` has made the items' Products in Stripe (shop.json `catalog`
|
|
30
|
+
* names the modes), a checkout names its item's Product, so the Dashboard, Stripe's reports and Stripe's MCP see sales
|
|
31
|
+
* by product. The price is always shop.json's (the rules checked it), never one read from Stripe. A Product missing
|
|
32
|
+
* in this mode falls back to the item described inline, so a sale never fails over the catalog.
|
|
27
33
|
*/
|
|
28
34
|
import { players } from './players.mjs';
|
|
29
35
|
import { serversOf } from './servers.mjs';
|
|
30
|
-
import { CAP_CEILING, audienceOf, bandOf, checkShop, money, wayFor } from './shop-rules.mjs';
|
|
36
|
+
import { CAP_CEILING, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
|
|
31
37
|
import {
|
|
32
38
|
bandOfPlayer, forgetPlayerShop, grantStatements, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, ownsOf, restoreStatement, revokeStatement,
|
|
33
39
|
setBand, shopDataOf, spentThisMonth,
|
|
34
40
|
} from './shop-store.mjs';
|
|
35
|
-
import { KEY_SHAPE, StripeError, WEBHOOK_SECRET_SHAPE, createCheckoutSession, createRefund, dashboardLink, modeOf, verifyWebhook } from './stripe.mjs';
|
|
41
|
+
import { KEY_SHAPE, StripeError, WEBHOOK_SECRET_SHAPE, createCheckoutSession, createRefund, dashboardLink, isApprovalRequired, isMissingProduct, modeOf, productIdOf, verifyWebhook } from './stripe.mjs';
|
|
36
42
|
import { arrivalOf, booksOf, lineFor, receiveStatement, referrerOk, sendStatements, settle, statementFor, statementsIn, voidLineStatements } from './referrals.mjs';
|
|
37
43
|
import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
|
|
38
44
|
import { notFoundPage } from './site.mjs';
|
|
@@ -270,11 +276,10 @@ async function ageRoute(request, env) {
|
|
|
270
276
|
return json({ ok: true, asked: true });
|
|
271
277
|
}
|
|
272
278
|
|
|
273
|
-
function sessionParams(shop, item, order, { origin, amount, player, game, parent = false, studio }) {
|
|
279
|
+
export function sessionParams(shop, item, order, { origin, amount, player, game, parent = false, studio, product = null }) {
|
|
274
280
|
const managed = shop.till === 'stripe-managed';
|
|
275
|
-
const limited = Boolean(item.days || item.ends);
|
|
276
281
|
// Managed Payments needs an eligible digital-goods tax code on every product (video games, downloaded).
|
|
277
|
-
const taxCode = item.taxCode ?? (managed ? (
|
|
282
|
+
const taxCode = item.taxCode ?? (managed ? defaultTaxCode(item) : undefined);
|
|
278
283
|
const back = game ? `&game=${encodeURIComponent(game)}` : '';
|
|
279
284
|
return {
|
|
280
285
|
mode: 'payment',
|
|
@@ -282,7 +287,8 @@ function sessionParams(shop, item, order, { origin, amount, player, game, parent
|
|
|
282
287
|
quantity: 1,
|
|
283
288
|
price_data: {
|
|
284
289
|
currency: shop.currency, unit_amount: amount, tax_behavior: 'exclusive',
|
|
285
|
-
|
|
290
|
+
// The catalog's Product (its name and tax code are Stripe's copy of shop.json's), or the item described inline.
|
|
291
|
+
...(product ? { product } : { product_data: { name: item.name.slice(0, 120), ...(item.blurb ? { description: item.blurb.slice(0, 300) } : {}), ...(taxCode ? { tax_code: taxCode } : {}), metadata: { item: item.id } } }),
|
|
286
292
|
},
|
|
287
293
|
}],
|
|
288
294
|
client_reference_id: player,
|
|
@@ -306,8 +312,18 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
|
|
|
306
312
|
await env.DB.prepare("INSERT INTO shop_orders (id, player, item, game, amount, currency, till, mode, status, parent, via, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'started', ?9, ?10, ?11, ?11)")
|
|
307
313
|
.bind(order.id, player.id, item.id, game ?? item.game ?? null, amount, shop.currency, shop.till, modeOf(env.STRIPE_KEY), parent ? 1 : 0, order.via, now).run();
|
|
308
314
|
let session = null;
|
|
315
|
+
const mode = modeOf(env.STRIPE_KEY);
|
|
316
|
+
const product = Array.isArray(shop.catalog) && shop.catalog.includes(mode) ? productIdOf(cat.studio?.slug, item.id) : null;
|
|
317
|
+
const params = (p) => sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name, product: p });
|
|
309
318
|
try {
|
|
310
|
-
|
|
319
|
+
try {
|
|
320
|
+
session = await createCheckoutSession(env, params(product), { idempotencyKey: `checkout-${order.id}` });
|
|
321
|
+
} catch (error) {
|
|
322
|
+
// shop.json says the catalog is made in this mode, but Stripe has no such Product (made in the sandbox only, or
|
|
323
|
+
// archived by hand): the item described inline still sells, and `homie-studio shop catalog` says what to make.
|
|
324
|
+
if (!product || !isMissingProduct(error)) throw error;
|
|
325
|
+
session = await createCheckoutSession(env, params(null), { idempotencyKey: `checkout-${order.id}-inline` });
|
|
326
|
+
}
|
|
311
327
|
} catch (error) {
|
|
312
328
|
await env.DB.prepare("UPDATE shop_orders SET status = 'failed', note = ?2, updated_at = ?3 WHERE id = ?1").bind(order.id, String(error?.code ?? 'stripe').slice(0, 60), Date.now()).run();
|
|
313
329
|
const why = error instanceof StripeError ? error.message : 'Stripe did not answer';
|
|
@@ -441,6 +457,7 @@ async function selfRefundRoute(request, env, shop, ready) {
|
|
|
441
457
|
if (!refundableNow(orderView(o), Number(used?.n) ? [{ item: o.item, used: true }] : [], shop)) return fail(403, 'not-refundable', `Only an unused item, within ${shop.refundDays} days. Ask the studio for anything else.`);
|
|
442
458
|
if (!KEY_SHAPE.test(String(env.STRIPE_KEY ?? ''))) return fail(503, 'closed', MISSING_WORDS['stripe-key']);
|
|
443
459
|
const r = await refundOrder(env, o, { reason: 'requested_by_customer', by: 'player' });
|
|
460
|
+
if (r.held) return json({ ok: false, held: true, error: 'held', message: 'The studio approves this refund in Stripe first. Your money comes back once they do, and the item leaves your account then.' }, 202);
|
|
444
461
|
return json(r, r.ok ? 200 : 502);
|
|
445
462
|
}
|
|
446
463
|
|
|
@@ -553,6 +570,9 @@ async function disputeClosed(env, payment, status) {
|
|
|
553
570
|
return 'kept';
|
|
554
571
|
}
|
|
555
572
|
|
|
573
|
+
/** What the owner reads when Stripe holds a refund for approval (the shop's key is an Agent key). */
|
|
574
|
+
export const REFUND_HELD = 'Stripe is holding this refund until a person approves it in Stripe (Settings, Approvals, Requests): the shop\'s Stripe key is marked as an Agent key, and Stripe asks a person before an agent\'s refund. Approve it there; Stripe then refunds, and the item leaves the player\'s account when Stripe says so. To refund with one tap again, connect the shop with a plain restricted key (homie-studio shop connect), not one made for "Authorizing agent access".';
|
|
575
|
+
|
|
556
576
|
/** Refund one order in full with Stripe, then take back that one item. The webhook confirms the same (idempotent). */
|
|
557
577
|
export async function refundOrder(env, o, { reason = 'requested_by_customer', by = 'owner' } = {}) {
|
|
558
578
|
if (o.status !== 'paid') return { ok: false, error: 'state', message: o.status === 'disputed' ? 'This order is disputed: the card network decides it now (answer it in Stripe).' : `This order is ${o.status}, not paid.` };
|
|
@@ -561,6 +581,9 @@ export async function refundOrder(env, o, { reason = 'requested_by_customer', by
|
|
|
561
581
|
try {
|
|
562
582
|
refund = await createRefund(env, { payment_intent: o.payment, reason: ['duplicate', 'fraudulent', 'requested_by_customer'].includes(reason) ? reason : 'requested_by_customer', metadata: { order: o.id, by } }, { idempotencyKey: `refund-${o.id}` });
|
|
563
583
|
} catch (error) {
|
|
584
|
+
// An Agent-tagged key: Stripe holds an agent's refund for a person's approval. Nothing happened yet; once someone
|
|
585
|
+
// approves it in Stripe, Stripe refunds and the webhook (refund.created, charge.refunded) takes the item back.
|
|
586
|
+
if (isApprovalRequired(error)) return { ok: false, error: 'approval', held: true, message: REFUND_HELD };
|
|
564
587
|
return { ok: false, error: 'stripe', message: `Stripe did not refund it (${error instanceof StripeError ? error.message : 'no answer'}). Nothing changed.` };
|
|
565
588
|
}
|
|
566
589
|
const now = Date.now();
|
package/worker/stripe.mjs
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
*
|
|
5
5
|
* createCheckoutSession(env, params) POST /v1/checkout/sessions (Stripe's hosted page; the TV never calls it)
|
|
6
6
|
* createRefund(env, params) POST /v1/refunds (only ever from the owner's tap, or a confirmed ask)
|
|
7
|
+
* webhook endpoints list, create and turn off: only `homie-studio shop connect` calls these, on the
|
|
8
|
+
* owner's computer with the key the owner pasted there (0.24.3), so a new
|
|
9
|
+
* endpoint's signing secret goes straight to the Worker secret
|
|
10
|
+
* productIdOf(slug, item) the catalog Product a shop item is (`homie-studio shop catalog`, 0.24.3)
|
|
7
11
|
* verifyWebhook(payload, header, secret) the `Stripe-Signature` header: t=<seconds>,v1=<hex HMAC-SHA256 of
|
|
8
12
|
* "<t>.<payload>"> with the endpoint's whsec_ secret, within 5 minutes
|
|
9
13
|
*
|
|
@@ -54,9 +58,30 @@ export class StripeError extends Error {
|
|
|
54
58
|
this.status = status;
|
|
55
59
|
this.code = e.code ?? e.type ?? 'stripe';
|
|
56
60
|
this.type = e.type ?? null;
|
|
61
|
+
this.param = typeof e.param === 'string' ? e.param.slice(0, 120) : null;
|
|
57
62
|
}
|
|
58
63
|
}
|
|
59
64
|
|
|
65
|
+
/** Stripe has no such Product (a catalog Product the shop names, in a mode the catalog was never made in). */
|
|
66
|
+
export const isMissingProduct = (error) => error instanceof StripeError && error.code === 'resource_missing' && /product/.test(String(error.param ?? error.message));
|
|
67
|
+
/**
|
|
68
|
+
* Stripe is holding the call for a person's approval: an Agent-tagged key's refund meets Stripe's approval rules
|
|
69
|
+
* (docs.stripe.com/account/approvals). Nothing happened yet; it happens when someone approves it in Stripe.
|
|
70
|
+
*/
|
|
71
|
+
export const isApprovalRequired = (error) => error instanceof StripeError && error.code === 'approval_required';
|
|
72
|
+
/** The key may not do this (a restricted key without the permission). */
|
|
73
|
+
export const isPermissionError = (error) => error instanceof StripeError && (error.status === 403 || error.status === 401 || /permission|restricted key/i.test(error.message));
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The catalog Product a shop item is, the same id in a sandbox and in live mode: `homie_<studio slug>_<item id>`
|
|
77
|
+
* (Stripe takes a product id of our choosing). Null without a slug or an item id.
|
|
78
|
+
*/
|
|
79
|
+
export function productIdOf(slug, item) {
|
|
80
|
+
const s = String(slug ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 48);
|
|
81
|
+
const i = String(item ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
|
|
82
|
+
return s && i ? `homie_${s}_${i}` : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
60
85
|
/** One call with the studio's key. `idempotencyKey` makes a retried POST do the work once (Stripe keeps it 24 h). */
|
|
61
86
|
export async function stripeCall(env, method, path, params = null, { idempotencyKey = null, fetcher = fetch } = {}) {
|
|
62
87
|
const key = String(env?.STRIPE_KEY ?? '');
|
|
@@ -83,6 +108,11 @@ export async function stripeCall(env, method, path, params = null, { idempotency
|
|
|
83
108
|
export const createCheckoutSession = (env, params, opts) => stripeCall(env, 'POST', '/v1/checkout/sessions', params, opts);
|
|
84
109
|
export const retrieveCheckoutSession = (env, id, opts) => stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(id)}`, null, opts);
|
|
85
110
|
export const createRefund = (env, params, opts) => stripeCall(env, 'POST', '/v1/refunds', params, opts);
|
|
111
|
+
export const expireCheckoutSession = (env, id, opts) => stripeCall(env, 'POST', `/v1/checkout/sessions/${encodeURIComponent(id)}/expire`, {}, opts);
|
|
112
|
+
export const listWebhookEndpoints = (env, opts) => stripeCall(env, 'GET', '/v1/webhook_endpoints', { limit: 100 }, opts);
|
|
113
|
+
/** The answer carries the endpoint's signing secret (`secret`), once: the caller puts it in the Worker and drops it. */
|
|
114
|
+
export const createWebhookEndpoint = (env, params, opts) => stripeCall(env, 'POST', '/v1/webhook_endpoints', params, opts);
|
|
115
|
+
export const updateWebhookEndpoint = (env, id, params, opts) => stripeCall(env, 'POST', `/v1/webhook_endpoints/${encodeURIComponent(id)}`, params, opts);
|
|
86
116
|
|
|
87
117
|
/* ------------------------------------------------------------------ webhooks */
|
|
88
118
|
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.24.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.24.4';
|