@homie-rocks/studio 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -1
- package/README.md +22 -0
- package/bin/homie-studio.mjs +34 -1
- package/lib/build.mjs +13 -2
- package/lib/scaffold.mjs +48 -2
- package/lib/shop.mjs +270 -0
- package/lib/template-history.json +39 -0
- package/netplay/NETPLAY.md +3 -1
- package/netplay/netplay.ts +6 -0
- package/package.json +6 -1
- package/shop/SELLING.md +83 -0
- package/shop/SHOP.md +154 -0
- package/shop/shop.ts +130 -0
- package/site/SITE.md +11 -0
- package/worker/account-page.mjs +14 -2
- package/worker/index.mjs +43 -4
- package/worker/office-page.mjs +5 -5
- package/worker/office.mjs +36 -0
- package/worker/pages.mjs +7 -5
- package/worker/players.mjs +8 -0
- package/worker/referrals.mjs +328 -0
- package/worker/room.mjs +3 -1
- package/worker/shop-page.mjs +532 -0
- package/worker/shop-rules.mjs +277 -0
- package/worker/shop-store.mjs +217 -0
- package/worker/shop.mjs +686 -0
- package/worker/stripe.mjs +149 -0
- package/worker/version.mjs +1 -1
package/worker/office-page.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { SKILLS } from './agents.mjs';
|
|
|
10
10
|
import { KIDS_LINE, POLICY_WORDS, SERVER_LIMITS } from './servers.mjs';
|
|
11
11
|
import { esc } from './site.mjs';
|
|
12
12
|
|
|
13
|
-
const PRIVATE = {
|
|
13
|
+
export const PRIVATE = {
|
|
14
14
|
'content-type': 'text/html; charset=utf-8',
|
|
15
15
|
'cache-control': 'no-store, private',
|
|
16
16
|
'x-robots-tag': 'noindex, nofollow',
|
|
@@ -832,7 +832,7 @@ async function officeScriptHash() {
|
|
|
832
832
|
return scriptHash;
|
|
833
833
|
}
|
|
834
834
|
|
|
835
|
-
function shell(cat, title, body, { status = 200, script = null, extra = {} } = {}) {
|
|
835
|
+
export function shell(cat, title, body, { status = 200, script = null, extra = {} } = {}) {
|
|
836
836
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
837
837
|
<meta name="robots" content="noindex, nofollow"><title>${esc(title)}</title><style>:root{--accent:${accentOf(cat)}}${CSS}</style></head><body><div class="wrap">${body}</div>${script ? `<script>${script}</script>` : ''}</body></html>`;
|
|
838
838
|
}
|
|
@@ -842,7 +842,7 @@ export async function officePage(cat, headers = {}) {
|
|
|
842
842
|
const name = cat.studio?.name ?? 'Studio';
|
|
843
843
|
const options = (cat.games ?? []).map((g) => `<option value="${esc(g.id)}">every room of ${esc(g.name)}</option>`).join('');
|
|
844
844
|
const body = `<header class="top"><h1>${esc(name)} <small>Office</small></h1>
|
|
845
|
-
<div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a>
|
|
845
|
+
<div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a>${cat.shop ? '<a href="/_studio/office/shop">Shop</a>' : ''}
|
|
846
846
|
<form method="post" action="/_studio/signout" style="display:inline"><button class="ghost small" type="submit">Sign out</button></form></div></header>
|
|
847
847
|
<form class="bar" id="announce-all"><label for="announce-text">Announce</label><input type="text" id="announce-text" maxlength="280" placeholder="A line every player sees, in the game" autocomplete="off">
|
|
848
848
|
<select id="announce-scope" aria-label="Who sees it"><option value="">every room of every game</option>${options}</select><button type="submit">Send</button>
|
|
@@ -871,7 +871,7 @@ export function lockedPage(cat, { what = 'office', ask = null } = {}) {
|
|
|
871
871
|
return new Response(shell(cat, `${what === 'confirm' ? 'Confirm' : 'Office'} · private`, body), { status: 401, headers: { ...PRIVATE, 'content-security-policy': NO_SCRIPT_CSP } });
|
|
872
872
|
}
|
|
873
873
|
|
|
874
|
-
const VERB = { 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' };
|
|
874
|
+
const VERB = { refund: 'Refund it', 'shop-settle': 'Mark it paid', kick: 'Kick', mute: 'Mute', close: 'Close the room', announce: 'Announce', game: 'Change it', 'server-set': 'Change it', 'server-close': 'Close it', member: 'Remove', 'agents-brain': 'Let them talk' };
|
|
875
875
|
|
|
876
876
|
/** The owner's one tap for what their AI asked, and what came of it. */
|
|
877
877
|
export function confirmPage(cat, ask, { missing = false } = {}) {
|
|
@@ -883,7 +883,7 @@ export function confirmPage(cat, ask, { missing = false } = {}) {
|
|
|
883
883
|
const verb = ask.action?.op === 'mute' && ask.action.off ? 'Unmute' : ask.action?.op === 'close' && ask.action.reopen ? 'Open it again' : VERB[ask.action?.op] ?? 'Yes';
|
|
884
884
|
body = `<h1>${esc(name)} <small>Your AI asks</small></h1>
|
|
885
885
|
<p style="font-size:19px;line-height:1.45;max-width:46ch">${esc(ask.what)}</p>
|
|
886
|
-
<form method="post" class="bar" style="background:transparent;border:0;padding:0"><button type="submit" name="do" value="yes" class="${['kick', 'close', 'server-close', 'member'].includes(ask.action?.op) && !ask.action?.reopen ? 'armed' : ''}">${esc(verb)}</button><button type="submit" name="do" value="no" class="ghost">No</button></form>
|
|
886
|
+
<form method="post" class="bar" style="background:transparent;border:0;padding:0"><button type="submit" name="do" value="yes" class="${['kick', 'close', 'server-close', 'member', 'refund'].includes(ask.action?.op) && !ask.action?.reopen ? 'armed' : ''}">${esc(verb)}</button><button type="submit" name="do" value="no" class="ghost">No</button></form>
|
|
887
887
|
<p class="note">Only you can confirm this: your AI asked through the studio's office key, which cannot say yes. It lasts until ${esc(new Date(ask.expiresAt).toISOString().slice(11, 16))} UTC.</p>`;
|
|
888
888
|
} else {
|
|
889
889
|
const said = { done: 'Done.', failed: 'That did not work.', cancelled: 'Not done: you said no.', expired: 'This ask ran out (15 minutes). Your AI can ask again.', working: 'Working on it…' }[ask.state] ?? ask.state;
|
package/worker/office.mjs
CHANGED
|
@@ -49,6 +49,10 @@
|
|
|
49
49
|
* reports players made (each the one message it is about) with Dismiss, and the review's day (Workers AI neurons).
|
|
50
50
|
* From an office key, a change that opens chat up (more allowed, fewer checks) is an ASK; one that tightens it, or
|
|
51
51
|
* taking a message down, happens at once.
|
|
52
|
+
*
|
|
53
|
+
* THE SHOP (0.24.0, worker/shop.mjs): /_studio/office/shop and /_studio/api/shop (sales, refunds, disputes, payouts as
|
|
54
|
+
* links to the studio's own Stripe, a CSV, the referral books and signed statements). A refund is the owner's one tap;
|
|
55
|
+
* from an office key it is ALWAYS an ask (the AI may only propose one), and so is marking a referrer paid.
|
|
52
56
|
*/
|
|
53
57
|
import { SEAT_MAX, seatsOf } from './seats.mjs';
|
|
54
58
|
import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
|
|
@@ -60,6 +64,8 @@ import { confirmPage, lockedPage, officePage } from './office-page.mjs';
|
|
|
60
64
|
import { licenseOf } from './license.mjs';
|
|
61
65
|
import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
|
|
62
66
|
import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
|
|
67
|
+
import { checkRefund, checkSettle, describeRefund, officeShopPage, ordersCsv, performRefund, settleReferrer, shopOffice, statementsOut, statementsSend } from './shop.mjs';
|
|
68
|
+
import { orderById } from './shop-store.mjs';
|
|
63
69
|
|
|
64
70
|
export { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from './office-schema.mjs';
|
|
65
71
|
|
|
@@ -673,6 +679,8 @@ function checkAction(cat, op, body) {
|
|
|
673
679
|
if (!(level >= 1 && level <= 5)) return bad('level is 1 to 5 (Rookie, Steady, Fair, Strong, Maxed)');
|
|
674
680
|
return { ok: true, action: { op, game: meta.id, room, level } };
|
|
675
681
|
}
|
|
682
|
+
case 'refund': return checkRefund(body);
|
|
683
|
+
case 'shop-settle': return checkSettle(body);
|
|
676
684
|
case 'agents-brain': {
|
|
677
685
|
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
678
686
|
if (typeof body.server !== 'string' || !(body.server === 'public' || /^[a-z0-9][a-z0-9-]{1,19}$/.test(body.server))) return bad('server is the server\'s id');
|
|
@@ -770,6 +778,8 @@ export function describe(cat, a) {
|
|
|
770
778
|
case 'member': return a.remove ? `Remove ${a.name ?? 'that player'} from ${gname}'s server ${a.server} (they can join again unless its door keeps them out).` : `Make ${a.name ?? 'that player'} a ${a.role} of ${gname}'s server ${a.server}.`;
|
|
771
779
|
case 'pass': return a.action === 'revoke' ? `Revoke the agent pass ${a.id}: that AI leaves every room and cannot sit again.` : `Issue an agent pass for an AI called "${a.label} · AI".`;
|
|
772
780
|
case 'room-level': return `Set the AI in ${where} to ${levelName(a.level)} (level ${a.level}).`;
|
|
781
|
+
case 'refund': return describeRefund(cat, a, a.item ? { item: a.item, amount: a.amount, currency: a.currency } : null);
|
|
782
|
+
case 'shop-settle': return `Mark what this studio owes ${a.via} for referrals as paid${a.ref ? ` (${a.ref})` : ''}: its owed lines are settled in the books.`;
|
|
773
783
|
case 'agents-brain': return ['workers-ai', 'owner-key'].includes(a.mode)
|
|
774
784
|
? `Let the AI guides on ${gname}'s server ${a.server} talk: they speak only the lines the game's own agents.json gives them (never free text), at most one line every 8 seconds, never about a person, and a player can quiet them. Their brain runs on ${a.mode === 'workers-ai' ? `this studio's own Workers AI (free allowance${a.budget !== undefined ? `; at most ${Math.round(a.budget).toLocaleString('en-US')} neurons a day` : ''})` : `your own AI provider key (claude-haiku-4-5, your money${a.budget !== undefined ? `, at most $${Number(a.budget).toFixed(2)} a day` : ', capped daily'})`}.`
|
|
775
785
|
: `Set the AI guides' brain on ${gname}'s server ${a.server} to ${a.mode}.`;
|
|
@@ -800,6 +810,8 @@ export function describe(cat, a) {
|
|
|
800
810
|
/** Whether an office key (the owner's AI) only ASKS for this (DESIGN D13): what takes something away, or consent. */
|
|
801
811
|
export async function needsAsk(env, cat, a) {
|
|
802
812
|
if (DESTRUCTIVE.has(a.op)) return true;
|
|
813
|
+
// Money: the owner's AI may only propose a refund or a settlement; the owner says yes.
|
|
814
|
+
if (a.op === 'refund' || a.op === 'shop-settle') return true;
|
|
803
815
|
if (a.op === 'server-close') return !a.reopen;
|
|
804
816
|
if (a.op === 'member') return a.remove === true;
|
|
805
817
|
if (a.op === 'server-set') {
|
|
@@ -1020,6 +1032,8 @@ export async function perform(env, cat, a) {
|
|
|
1020
1032
|
if (a.mode === 'off') notes.push('The guides are the game\'s plain bots.');
|
|
1021
1033
|
return { ok: true, op: a.op, game: meta.id, server: sv.id, brain: sv.brain, budget: day.budget, note: `Saved. ${notes.join(' ')}`.trim() };
|
|
1022
1034
|
}
|
|
1035
|
+
case 'refund': return performRefund(env, cat, a);
|
|
1036
|
+
case 'shop-settle': return settleReferrer(env, a);
|
|
1023
1037
|
default: return { ok: false, error: 'op' };
|
|
1024
1038
|
}
|
|
1025
1039
|
}
|
|
@@ -1155,11 +1169,21 @@ async function api(request, env, url, cat) {
|
|
|
1155
1169
|
const view = await officeView(env, { ...cat, games: metas }, url.origin);
|
|
1156
1170
|
return json({ ok: true, day: view.chat, games: view.games.map((g) => ({ id: g.id, name: g.name, chat: g.chat, rooms: g.rooms.map((r) => ({ room: r.room, label: r.label, players: r.players, chat: r.chat })) })) });
|
|
1157
1171
|
}
|
|
1172
|
+
// The shop (0.24.0): the owner's view, the accountant's CSV, the referral statements.
|
|
1173
|
+
if (path === '/_studio/api/shop' && request.method === 'GET') return json(await shopOffice(env, cat, url.origin));
|
|
1174
|
+
if (path === '/_studio/api/shop/orders.csv' && request.method === 'GET') {
|
|
1175
|
+
try {
|
|
1176
|
+
return new Response(await ordersCsv(env), { headers: { 'content-type': 'text/csv; charset=utf-8', 'cache-control': 'no-store, private', 'content-disposition': `attachment; filename="orders-${today()}.csv"` } });
|
|
1177
|
+
} catch { return json({ ok: false, error: 'not-migrated', message: 'The shop needs migration 0008_studio_shop.sql (npm run deploy).' }, 503); }
|
|
1178
|
+
}
|
|
1179
|
+
if (path === '/_studio/api/shop/statements' && request.method === 'GET') return json(await statementsOut(env, cat, url.origin, url.searchParams.get('period')));
|
|
1180
|
+
if (path === '/_studio/api/shop/statements/send' && request.method === 'POST') return json(await statementsSend(env, cat, url.origin, body.period));
|
|
1158
1181
|
const OPS = {
|
|
1159
1182
|
'/_studio/api/chat/rules': 'chat-rules', '/_studio/api/chat/remove': 'chat-remove', '/_studio/api/chat/report': 'chat-report', '/_studio/api/chat/budget': 'chat-budget',
|
|
1160
1183
|
'/_studio/api/kick': 'kick', '/_studio/api/mute': 'mute', '/_studio/api/close': 'close', '/_studio/api/announce': 'announce', '/_studio/api/game': 'game',
|
|
1161
1184
|
'/_studio/api/servers': 'server-create', '/_studio/api/servers/set': 'server-set', '/_studio/api/servers/close': 'server-close', '/_studio/api/servers/member': 'member',
|
|
1162
1185
|
'/_studio/api/agents/pass': 'pass', '/_studio/api/room-level': 'room-level', '/_studio/api/agents/brain': 'agents-brain',
|
|
1186
|
+
'/_studio/api/shop/refund': 'refund', '/_studio/api/shop/settle': 'shop-settle',
|
|
1163
1187
|
};
|
|
1164
1188
|
const op = OPS[path];
|
|
1165
1189
|
if (!op || request.method !== 'POST') return json({ ok: false, error: 'not-found' }, 404);
|
|
@@ -1167,6 +1191,12 @@ async function api(request, env, url, cat) {
|
|
|
1167
1191
|
if (!checked.ok) return json(checked, 400);
|
|
1168
1192
|
const action = checked.action;
|
|
1169
1193
|
if (op === 'server-create' || op === 'pass') action.origin = url.origin;
|
|
1194
|
+
if (op === 'refund') {
|
|
1195
|
+
// The ask and the confirm page say what the order is (its item and price), read from the books now.
|
|
1196
|
+
const o = await orderById(env, action.order);
|
|
1197
|
+
if (!o) return json({ ok: false, error: 'order', message: 'No such order (homie-studio shop orders lists them).' }, 404);
|
|
1198
|
+
Object.assign(action, { item: o.item, amount: Number(o.amount), currency: o.currency });
|
|
1199
|
+
}
|
|
1170
1200
|
// An office key (the owner's AI) only ASKS for what takes something away; the owner confirms with one tap.
|
|
1171
1201
|
if (who === 'office' && await needsAsk(env, cat, action)) {
|
|
1172
1202
|
if (op === 'kick' || op === 'mute') {
|
|
@@ -1199,6 +1229,12 @@ export async function officeRoutes(request, env, url, { catalogueOf }) {
|
|
|
1199
1229
|
const secure = url.protocol === 'https:' ? '; Secure' : '';
|
|
1200
1230
|
return officePage(cat, session ? { 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } : {});
|
|
1201
1231
|
}
|
|
1232
|
+
if (path === '/_studio/office/shop' || path === '/_studio/office/shop/') {
|
|
1233
|
+
const cat = await catalogueOf();
|
|
1234
|
+
if (request.method !== 'GET') return new Response('method', { status: 405 });
|
|
1235
|
+
if (!(await isOwner(request, env))) return lockedPage(cat, { what: 'office' });
|
|
1236
|
+
return officeShopPage(cat);
|
|
1237
|
+
}
|
|
1202
1238
|
const confirm = /^\/_studio\/confirm\/(ask_[a-f0-9]{16})\/?$/.exec(path);
|
|
1203
1239
|
if (confirm) {
|
|
1204
1240
|
const cat = await catalogueOf();
|
package/worker/pages.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { SKILLS } from './agents.mjs';
|
|
|
8
8
|
import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
|
|
9
9
|
import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
|
|
10
10
|
import { CHAT_CSS, CHAT_JS, CHAT_OWNER_JS, chatBoot } from './chat-page.mjs';
|
|
11
|
+
import { SHOP_SHELL_CSS, SHOP_SHELL_JS } from './shop-page.mjs';
|
|
11
12
|
|
|
12
13
|
export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
|
|
13
14
|
|
|
@@ -85,7 +86,7 @@ export function sharePlaces(value) {
|
|
|
85
86
|
* the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
|
|
86
87
|
* covers the middle of the screen or a thumb.
|
|
87
88
|
*/
|
|
88
|
-
export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null, acct = false, member = false } = {}) {
|
|
89
|
+
export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null, acct = false, member = false, shop = null } = {}) {
|
|
89
90
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
90
91
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
91
92
|
const places = sharePlaces(g.screen?.share);
|
|
@@ -160,14 +161,14 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
160
161
|
.notice .acts a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border-radius: 12px; text-decoration: none; font-weight: 700; color: #eef1f8; border: 1px solid rgba(255,255,255,.18); }
|
|
161
162
|
.notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
|
|
162
163
|
[hidden] { display: none !important; }
|
|
163
|
-
${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
|
|
164
|
+
${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHOP_SHELL_CSS : ''}${pillUi}`;
|
|
164
165
|
// The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
|
|
165
166
|
// A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
|
|
166
167
|
// The server this page plays on (0.16.0): its pool (the Lobby), its badge and line for the chip, its ceiling.
|
|
167
168
|
const srv = server ? { id: server.id, name: server.name, badge: server.badge, line: server.line, policy: server.policy, kids: Boolean(server.kids), mentor: Boolean(server.mentor), levelMax: server.levelMax } : null;
|
|
168
169
|
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
169
170
|
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
170
|
-
const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE };
|
|
171
|
+
const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
171
172
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
172
173
|
const joinCorner = corner('join', 'bottom-right');
|
|
173
174
|
const first = places.desk;
|
|
@@ -190,7 +191,7 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
|
|
|
190
191
|
<a data-bigscreen target="_blank" rel="noopener" href="/${esc(g.id)}/tv"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="4" width="19" height="13" rx="2"/><path d="M8 20.5h8"/></svg><span>Big screen</span></a>
|
|
191
192
|
</div>
|
|
192
193
|
<span class="link" data-room-link></span>
|
|
193
|
-
<div class="foot"><a href="/${esc(g.id)}/">← ${esc(g.name)}</a
|
|
194
|
+
<div class="foot"><a href="/${esc(g.id)}/">← ${esc(g.name)}</a>${shop ? '<button type="button" data-shop-open>Shop</button>' : ''}<button type="button" data-copy-link>Copy link</button></div>${g.saves ? `
|
|
194
195
|
<div class="who-row" data-who hidden><span data-who-name></span><button type="button" data-who-act></button></div>` : ''}
|
|
195
196
|
</div>
|
|
196
197
|
</div>
|
|
@@ -206,7 +207,8 @@ ${joinCard}${share}
|
|
|
206
207
|
<script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: screen ? 'tv' : 'play', owner, acct, member })).replace(/</g, '\\u003c')};</script>
|
|
207
208
|
<script>${CHAT_JS}</script>
|
|
208
209
|
<script>${SHELL_JS}</script>${g.saves && !screen ? `
|
|
209
|
-
<script>${SAVES_SHELL_JS}</script>` : ''}${
|
|
210
|
+
<script>${SAVES_SHELL_JS}</script>` : ''}${shop ? `
|
|
211
|
+
<script>${SHOP_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script><script>${CHAT_OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
|
|
210
212
|
}
|
|
211
213
|
|
|
212
214
|
/*
|
package/worker/players.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { cookieValue } from './stats.mjs';
|
|
|
30
30
|
import { adoptStatements, bumpPlayerStats, dropPlayerData, fall, fallenList, getSave, listSaves, playerData, readPlayerStats, SAVE_LIMITS, savesSummary, wipeSaves, writeSaves } from './saves.mjs';
|
|
31
31
|
import { bytesToB64url, randomToken, sha256Hex, verifyAssertion, verifyRegistration } from './webauthn.mjs';
|
|
32
32
|
import { ACCOUNT_JS, accountPage } from './account-page.mjs';
|
|
33
|
+
import { forgetPlayerShop, livePurchases, shopDataOf } from './shop-store.mjs';
|
|
33
34
|
|
|
34
35
|
export const PLAYERS_MIGRATION_FILE = '0004_players.sql';
|
|
35
36
|
export const PLAYERS_MIGRATION = `-- Players and cloud saves (@homie-rocks/studio 0.12.0): accounts on this studio only (saves/SAVES.md).
|
|
@@ -323,6 +324,8 @@ async function removePlayer(env, id) {
|
|
|
323
324
|
]);
|
|
324
325
|
// Their servers' memberships too (0.16.0); a studio before migration 0006 has none to delete.
|
|
325
326
|
try { await env.DB.prepare('DELETE FROM server_members WHERE player = ?1').bind(id).run(); } catch { /* not migrated */ }
|
|
327
|
+
// What they bought goes with them; the orders stay, without the player (0.24.0; none before migration 0008).
|
|
328
|
+
try { await env.DB.batch(forgetPlayerShop(env, id)); } catch { /* not migrated */ }
|
|
326
329
|
await data.after();
|
|
327
330
|
}
|
|
328
331
|
|
|
@@ -508,6 +511,8 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
|
|
|
508
511
|
player: me,
|
|
509
512
|
passkeys: keys.map((k) => ({ label: k.label, synced: Number(k.synced) === 1, createdAt: new Date(Number(k.created_at)).toISOString(), lastUsedAt: new Date(Number(k.used_at)).toISOString() })),
|
|
510
513
|
games: await playerData(env, s.player.id),
|
|
514
|
+
// The shop (0.24.0): their orders, what they own and the age band they gave; never a card or an email.
|
|
515
|
+
...(await shopDataOf(env, s.player.id).then((x) => (x ? { shop: x } : {}))),
|
|
511
516
|
note: 'Everything this studio keeps about this player. A passkey\'s private key never left your device; this site keeps only its public key.',
|
|
512
517
|
};
|
|
513
518
|
return json(body, 200, { 'content-disposition': `attachment; filename="${s.player.id}.json"` });
|
|
@@ -770,6 +775,9 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
|
|
|
770
775
|
|
|
771
776
|
if (head === 'delete' && parts.length === 1) {
|
|
772
777
|
if (body.confirm !== 'delete') return fail(400, 'confirm', 'send { "confirm": "delete" } to delete this player and everything they kept');
|
|
778
|
+
// Things they bought go with the account: say so first (the shop, 0.24.0).
|
|
779
|
+
const owned = await livePurchases(env, s.player.id).catch(() => 0);
|
|
780
|
+
if (owned && body.purchases !== 'forfeit') return fail(409, 'purchases', `This account owns ${owned} thing${owned === 1 ? '' : 's'} bought here; deleting it gives ${owned === 1 ? 'it' : 'them'} up for good (an unused item can be refunded first, from this page). Send { "confirm": "delete", "purchases": "forfeit" } to delete anyway.`, { owned });
|
|
773
781
|
await removePlayer(env, s.player.id);
|
|
774
782
|
return withCookies(json({ ok: true, deleted: s.player.id }), [clearCookie(url)]);
|
|
775
783
|
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REFERRALS (@homie-rocks/studio 0.24.0): one set of rules for every referrer, homie.rocks included.
|
|
3
|
+
*
|
|
4
|
+
* arrival A person's browser opens one of this studio's pages with ?via=<host> (a link from another studio,
|
|
5
|
+
* from homie.rocks, from anywhere). If this browser has never been here (no player of this site), the
|
|
6
|
+
* Worker keeps the host and the time in a signed cookie (HttpOnly, this site only, for the window).
|
|
7
|
+
* Prefetches, crawlers, this site itself and server rooms are never recorded.
|
|
8
|
+
* a sale A paid order within the window, by an account made after that first visit, writes a referral line in
|
|
9
|
+
* THIS studio's D1 (the seller's books): the pre-tax price, the published rate, the share, held for
|
|
10
|
+
* `holdDays` (a refund or a lost dispute inside the hold voids it; after it, it nets off the next
|
|
11
|
+
* statement). The referrer must prove itself: its own /.well-known/homie-studio.json says it takes
|
|
12
|
+
* referral statements. No list anywhere says who is real; homie.rocks is not a registry.
|
|
13
|
+
* statements Monthly, signed with this studio's own Ed25519 key (its public half is in the manifest): the order
|
|
14
|
+
* hashes, items, pre-tax amounts, dates and states, and what is owed. No player, name or email. POSTed
|
|
15
|
+
* to the referrer's /api/referrals/statement, which checks the signature against the seller's manifest.
|
|
16
|
+
* paying Never through Homie. The referrer invoices the seller from its own Stripe (or PayPal, Wise, or the
|
|
17
|
+
* seller's gift codes); the seller pays the invoice. The office shows both sides and links to Stripe.
|
|
18
|
+
*/
|
|
19
|
+
import { isVisit } from './stats.mjs';
|
|
20
|
+
|
|
21
|
+
const DAY = 86_400_000;
|
|
22
|
+
export const VIA_COOKIE = 'studio_via';
|
|
23
|
+
const HOST = /^(?=.{3,80}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
24
|
+
export const STATEMENT_KIND = 'homie-referral-statement';
|
|
25
|
+
|
|
26
|
+
const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
27
|
+
const fromB64url = (s) => Uint8Array.from(atob(String(s).replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (String(s).length % 4)) % 4)), (c) => c.charCodeAt(0));
|
|
28
|
+
const enc = new TextEncoder();
|
|
29
|
+
|
|
30
|
+
/** A host as a referrer names itself: lower-case, no port, no path, never an address. */
|
|
31
|
+
export function hostOf(raw) {
|
|
32
|
+
const h = String(raw ?? '').trim().toLowerCase().replace(/^www\./, '');
|
|
33
|
+
return HOST.test(h) && !/^\d+\.\d+\.\d+\.\d+$/.test(h) ? h : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** JSON with sorted keys: what a signature covers, byte for byte, on both sides. */
|
|
37
|
+
export function canonicalJson(v) {
|
|
38
|
+
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(',')}]`;
|
|
39
|
+
if (v && typeof v === 'object') return `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(',')}}`;
|
|
40
|
+
return JSON.stringify(v ?? null);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ------------------------------------------------------------------ this studio's secrets in D1 meta */
|
|
44
|
+
|
|
45
|
+
async function metaGet(env, key) {
|
|
46
|
+
try { return (await env.DB.prepare('SELECT value FROM meta WHERE key = ?1').bind(key).first())?.value ?? null; } catch { return null; }
|
|
47
|
+
}
|
|
48
|
+
async function metaPutOnce(env, key, value) {
|
|
49
|
+
await env.DB.prepare('INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO NOTHING').bind(key, value).run();
|
|
50
|
+
return metaGet(env, key);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let hmacCache = null;
|
|
54
|
+
/** The key that signs the arrival cookie (random, in D1 meta, made on first use). */
|
|
55
|
+
async function cookieKey(env) {
|
|
56
|
+
if (hmacCache?.env === env) return hmacCache.key;
|
|
57
|
+
let hex = await metaGet(env, 'referral_cookie_key');
|
|
58
|
+
if (!hex) {
|
|
59
|
+
const raw = new Uint8Array(32);
|
|
60
|
+
crypto.getRandomValues(raw);
|
|
61
|
+
hex = await metaPutOnce(env, 'referral_cookie_key', [...raw].map((b) => b.toString(16).padStart(2, '0')).join(''));
|
|
62
|
+
}
|
|
63
|
+
const bytes = Uint8Array.from(hex.match(/../g).map((h) => parseInt(h, 16)));
|
|
64
|
+
const key = await crypto.subtle.importKey('raw', bytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
65
|
+
hmacCache = { env, key };
|
|
66
|
+
return key;
|
|
67
|
+
}
|
|
68
|
+
const macOf = async (env, text) => b64url(await crypto.subtle.sign('HMAC', await cookieKey(env), enc.encode(text))).slice(0, 22);
|
|
69
|
+
|
|
70
|
+
let signingCache = null;
|
|
71
|
+
/** This studio's Ed25519 statement key (private half in D1 meta, made on first use; public half in the manifest). */
|
|
72
|
+
export async function signingKey(env) {
|
|
73
|
+
if (signingCache?.env === env) return signingCache.pair;
|
|
74
|
+
let stored = await metaGet(env, 'referral_signing_key');
|
|
75
|
+
if (!stored) {
|
|
76
|
+
const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
|
77
|
+
const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);
|
|
78
|
+
stored = await metaPutOnce(env, 'referral_signing_key', JSON.stringify({ kty: jwk.kty, crv: jwk.crv, d: jwk.d, x: jwk.x }));
|
|
79
|
+
}
|
|
80
|
+
const jwk = JSON.parse(stored);
|
|
81
|
+
const privateKey = await crypto.subtle.importKey('jwk', { ...jwk, key_ops: ['sign'], ext: true }, { name: 'Ed25519' }, false, ['sign']);
|
|
82
|
+
const pair = { privateKey, publicJwk: { kty: 'OKP', crv: 'Ed25519', x: jwk.x } };
|
|
83
|
+
signingCache = { env, pair };
|
|
84
|
+
return pair;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function signStatement(env, statement) {
|
|
88
|
+
const { privateKey, publicJwk } = await signingKey(env);
|
|
89
|
+
const sig = b64url(await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, enc.encode(canonicalJson(statement))));
|
|
90
|
+
return { v: 1, kind: STATEMENT_KIND, statement, sig, key: publicJwk.x };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Check a statement's signature against a public key (the `x` of the seller's manifest key). */
|
|
94
|
+
export async function verifyStatement(envelope, x) {
|
|
95
|
+
try {
|
|
96
|
+
if (envelope?.kind !== STATEMENT_KIND || typeof envelope.sig !== 'string' || typeof x !== 'string') return false;
|
|
97
|
+
const pub = await crypto.subtle.importKey('jwk', { kty: 'OKP', crv: 'Ed25519', x, ext: true }, { name: 'Ed25519' }, false, ['verify']);
|
|
98
|
+
return await crypto.subtle.verify({ name: 'Ed25519' }, pub, fromB64url(envelope.sig), enc.encode(canonicalJson(envelope.statement)));
|
|
99
|
+
} catch { return false; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ------------------------------------------------------------------ arrival */
|
|
103
|
+
|
|
104
|
+
/** Pages where an arrival may be recorded: the studio's own pages and Play; never a server's page or room. */
|
|
105
|
+
const ARRIVAL_PAGE = /^\/(?:|games\/|shop\/|[a-z0-9][a-z0-9-]{0,39}\/(?:play)?)$/;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A Set-Cookie for this arrival, or null. Only a person's real page load (not a prefetch, a crawler or this site's
|
|
109
|
+
* own link), with ?via=<host> of another site, from a browser with no player and no earlier arrival here, on a studio
|
|
110
|
+
* that sells and pays referrals, never on a server's page or room.
|
|
111
|
+
*/
|
|
112
|
+
export async function arrivalCookie(request, env, url, shop) {
|
|
113
|
+
if (!shop?.open || !shop.referrals || !env?.DB) return null;
|
|
114
|
+
const via = hostOf(url.searchParams.get('via'));
|
|
115
|
+
if (!via || via === hostOf(url.hostname)) return null;
|
|
116
|
+
if (!ARRIVAL_PAGE.test(url.pathname) || url.searchParams.has('room') || url.searchParams.has('server')) return null;
|
|
117
|
+
if (!isVisit(request)) return null;
|
|
118
|
+
const cookies = request.headers.get('cookie') ?? '';
|
|
119
|
+
// Been here before: a player of this site (a guest who saved, or an account), or an earlier arrival.
|
|
120
|
+
if (/(?:^|;\s*)(?:studio_player|studio_via)=/.test(cookies)) return null;
|
|
121
|
+
const at = Math.floor(Date.now() / 1000);
|
|
122
|
+
const value = `${via}.${at}.${await macOf(env, `${via}.${at}`)}`;
|
|
123
|
+
return `${VIA_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${shop.referrals.windowDays * 86400}${url.protocol === 'https:' ? '; Secure' : ''}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The arrival this request's browser carries ({ via, at }), checked, or null. */
|
|
127
|
+
export async function arrivalOf(request, env) {
|
|
128
|
+
const raw = /(?:^|;\s*)studio_via=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
|
|
129
|
+
const m = /^([a-z0-9.-]{3,80})\.(\d{9,11})\.([A-Za-z0-9_-]{22})$/.exec(raw ?? '');
|
|
130
|
+
if (!m || !hostOf(m[1])) return null;
|
|
131
|
+
try { if (m[3] !== await macOf(env, `${m[1]}.${m[2]}`)) return null; } catch { return null; }
|
|
132
|
+
return { via: m[1], at: Number(m[2]) * 1000 };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ------------------------------------------------------------------ is a referrer real? */
|
|
136
|
+
|
|
137
|
+
const proven = new Map();
|
|
138
|
+
/**
|
|
139
|
+
* Whether `host` takes referral statements: its own manifest says so (fetched over https, cached a day; a failure is
|
|
140
|
+
* cached an hour). `fetcher` is for tests.
|
|
141
|
+
*/
|
|
142
|
+
export async function referrerOk(host, { fetcher = fetch, now = Date.now() } = {}) {
|
|
143
|
+
const h = hostOf(host);
|
|
144
|
+
if (!h) return false;
|
|
145
|
+
const hit = proven.get(h);
|
|
146
|
+
if (hit && now - hit.at < (hit.ok ? DAY : 3_600_000)) return hit.ok;
|
|
147
|
+
let ok = false;
|
|
148
|
+
try {
|
|
149
|
+
const res = await fetcher(`https://${h}/.well-known/homie-studio.json`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(3000) });
|
|
150
|
+
if (res.ok) {
|
|
151
|
+
const m = await res.json();
|
|
152
|
+
const st = String(m?.referrals?.statements ?? '');
|
|
153
|
+
ok = m?.referrals?.accepts === true && st.startsWith(`https://${h}/`);
|
|
154
|
+
}
|
|
155
|
+
} catch { ok = false; }
|
|
156
|
+
proven.set(h, { ok, at: now });
|
|
157
|
+
if (proven.size > 2000) proven.clear();
|
|
158
|
+
return ok;
|
|
159
|
+
}
|
|
160
|
+
/** For tests: forget what was proven. */
|
|
161
|
+
export function resetReferrers() { proven.clear(); }
|
|
162
|
+
|
|
163
|
+
/** What the manifest says about referrals: this studio takes statements (as a referrer) and, if it sells, its terms. */
|
|
164
|
+
export async function manifestReferrals(env, origin, shop, studio) {
|
|
165
|
+
if (studio?.referrals === false || !env?.DB) return null;
|
|
166
|
+
let key = null;
|
|
167
|
+
try { key = (await signingKey(env)).publicJwk; } catch { key = null; }
|
|
168
|
+
return {
|
|
169
|
+
accepts: true,
|
|
170
|
+
statements: `${origin}/api/referrals/statement`,
|
|
171
|
+
...(key ? { key } : {}),
|
|
172
|
+
// The seller's terms, the same for every referrer (homie.rocks included); absent: this studio pays no referrals.
|
|
173
|
+
...(shop?.open && shop.referrals ? { terms: shop.referrals } : {}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ------------------------------------------------------------------ the seller's books */
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A paid order with an arrival on it: its referral line (pending, held), or nothing when the terms say no (rate 0, out
|
|
181
|
+
* of the window, the per-player cap reached). Statements for D1's batch.
|
|
182
|
+
*/
|
|
183
|
+
export async function lineFor(env, order, shop, { touchAt = null, now = Date.now() } = {}) {
|
|
184
|
+
const terms = shop?.referrals;
|
|
185
|
+
if (!terms || !order.via || !(terms.rate > 0)) return [];
|
|
186
|
+
if (touchAt !== null && now - touchAt > terms.windowDays * DAY) return [];
|
|
187
|
+
let so = 0;
|
|
188
|
+
try { so = Number((await env.DB.prepare("SELECT COALESCE(SUM(l.share), 0) AS n FROM referral_lines l JOIN shop_orders o ON o.id = l.order_id WHERE o.player = ?1 AND l.via = ?2 AND l.state != 'void'").bind(order.player, order.via).first())?.n) || 0; } catch { so = 0; }
|
|
189
|
+
const share = Math.max(0, Math.min(Math.round(Number(order.amount) * terms.rate), terms.capPerPlayer - so));
|
|
190
|
+
if (share <= 0) return [];
|
|
191
|
+
const period = new Date(now).toISOString().slice(0, 7);
|
|
192
|
+
return [env.DB.prepare("INSERT INTO referral_lines (order_id, via, net, rate, share, currency, state, period, hold_until, settled_ref, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, ?8, NULL, ?9) ON CONFLICT(order_id) DO NOTHING")
|
|
193
|
+
.bind(order.id, order.via, Number(order.amount), terms.rate, share, order.currency, period, now + terms.holdDays * DAY, now)];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A refund or a lost dispute: a held line is void; a settled one becomes a clawback that nets off the next statement. */
|
|
197
|
+
export function voidLineStatements(env, orderId) {
|
|
198
|
+
return [
|
|
199
|
+
env.DB.prepare("UPDATE referral_lines SET state = 'void' WHERE order_id = ?1 AND state IN ('pending', 'owed')").bind(orderId),
|
|
200
|
+
env.DB.prepare("UPDATE referral_lines SET state = 'clawback' WHERE order_id = ?1 AND state = 'settled'").bind(orderId),
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const lineState = (r, now) => (r.state === 'pending' && Number(r.hold_until) <= now ? 'owed' : r.state);
|
|
205
|
+
|
|
206
|
+
async function orderHash(seller, orderId) {
|
|
207
|
+
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', enc.encode(`${seller}|${orderId}`)))].slice(0, 8).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The seller's books per referrer: { via, currency, pending, owed, clawback, lines } (lines not yet settled, and
|
|
212
|
+
* clawbacks), newest first. `period` (YYYY-MM) limits lines to sales up to the end of that month.
|
|
213
|
+
*/
|
|
214
|
+
export async function booksOf(env, { period = null, now = Date.now() } = {}) {
|
|
215
|
+
let rows = [];
|
|
216
|
+
try {
|
|
217
|
+
rows = (await env.DB.prepare("SELECT l.*, o.item, o.paid_at FROM referral_lines l JOIN shop_orders o ON o.id = l.order_id WHERE l.state IN ('pending', 'owed', 'clawback', 'void') ORDER BY l.created_at DESC LIMIT 2000").all()).results ?? [];
|
|
218
|
+
} catch { return []; }
|
|
219
|
+
const by = new Map();
|
|
220
|
+
for (const r of rows) {
|
|
221
|
+
if (period && r.period > period) continue;
|
|
222
|
+
const b = by.get(r.via) ?? { via: r.via, currency: r.currency, pending: 0, owed: 0, clawback: 0, lines: [] };
|
|
223
|
+
const state = lineState(r, now);
|
|
224
|
+
if (state === 'pending') b.pending += Number(r.share);
|
|
225
|
+
if (state === 'owed') b.owed += Number(r.share);
|
|
226
|
+
if (state === 'clawback') b.clawback += Number(r.share);
|
|
227
|
+
b.lines.push({ order: r.order_id, item: r.item, net: Number(r.net), rate: Number(r.rate), share: Number(r.share), state, period: r.period, date: new Date(Number(r.paid_at ?? r.created_at)).toISOString().slice(0, 10), holdUntil: Number(r.hold_until) });
|
|
228
|
+
by.set(r.via, b);
|
|
229
|
+
}
|
|
230
|
+
return [...by.values()].map((b) => ({ ...b, due: b.owed - b.clawback }));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** One referrer's signed statement for a period (no player, name or email in it: order hashes only). */
|
|
234
|
+
export async function statementFor(env, origin, book, period, terms) {
|
|
235
|
+
const seller = origin;
|
|
236
|
+
const lines = [];
|
|
237
|
+
for (const l of book.lines) lines.push({ order: await orderHash(seller, l.order), item: l.item, net: l.net, share: l.share, date: l.date, state: l.state });
|
|
238
|
+
const statement = {
|
|
239
|
+
seller, referrer: book.via, period, currency: book.currency, issued: new Date().toISOString(),
|
|
240
|
+
terms: terms ? { rate: terms.rate, holdDays: terms.holdDays, minimumInvoice: terms.minimumInvoice, basis: 'pre-tax' } : null,
|
|
241
|
+
totals: { pending: book.pending, owed: book.owed, clawback: book.clawback, due: book.due },
|
|
242
|
+
lines,
|
|
243
|
+
note: 'Invoice the seller for "due" from your own Stripe (or the way its terms accept). Nothing moves through Homie.',
|
|
244
|
+
};
|
|
245
|
+
return signStatement(env, statement);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The owner marks what was paid (an invoice paid by card, a PayPal transfer): those owed lines are settled. */
|
|
249
|
+
export async function settle(env, via, ref, { now = Date.now() } = {}) {
|
|
250
|
+
const h = hostOf(via);
|
|
251
|
+
if (!h) return { ok: false, error: 'bad-request', message: 'via is the referrer\'s host' };
|
|
252
|
+
const note = String(ref ?? '').replace(/[^\w .:#/-]/g, '').slice(0, 80) || 'paid';
|
|
253
|
+
await env.DB.batch([
|
|
254
|
+
env.DB.prepare("UPDATE referral_lines SET state = 'settled', settled_ref = ?2 WHERE via = ?1 AND state = 'pending' AND hold_until <= ?3").bind(h, note, now),
|
|
255
|
+
env.DB.prepare("UPDATE referral_lines SET state = 'settled-clawback', settled_ref = ?2 WHERE via = ?1 AND state = 'clawback'").bind(h, note),
|
|
256
|
+
]);
|
|
257
|
+
return { ok: true, via: h, ref: note };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* ------------------------------------------------------------------ as a referrer */
|
|
261
|
+
|
|
262
|
+
const received = [];
|
|
263
|
+
/**
|
|
264
|
+
* POST /api/referrals/statement: a seller's signed statement to this studio. It must name this site as the referrer,
|
|
265
|
+
* and its signature must check against the key in the SELLER's own manifest (fetched now, over https). Kept per
|
|
266
|
+
* seller and period; a newer one replaces the older.
|
|
267
|
+
*/
|
|
268
|
+
export async function receiveStatement(request, env, url, { fetcher = fetch } = {}) {
|
|
269
|
+
const json = (b, s = 200) => new Response(`${JSON.stringify(b)}\n`, { status: s, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
|
|
270
|
+
if (request.method !== 'POST') return json({ ok: false, error: 'method' }, 405);
|
|
271
|
+
// A statement makes this Worker read the seller's manifest: a few a minute is all a real seller sends.
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
while (received.length && received[0] < now - 60_000) received.shift();
|
|
274
|
+
if (received.length >= 30) return json({ ok: false, error: 'rate', message: 'too many statements; try again in a minute' }, 429);
|
|
275
|
+
received.push(now);
|
|
276
|
+
if (Number(request.headers.get('content-length') ?? 0) > 65_536) return json({ ok: false, error: 'too-large' }, 413);
|
|
277
|
+
const text = await request.text();
|
|
278
|
+
if (text.length > 65_536) return json({ ok: false, error: 'too-large' }, 413);
|
|
279
|
+
let env0 = null;
|
|
280
|
+
try { env0 = JSON.parse(text); } catch { return json({ ok: false, error: 'json' }, 400); }
|
|
281
|
+
const s = env0?.statement;
|
|
282
|
+
if (env0?.kind !== STATEMENT_KIND || !s || typeof s !== 'object') return json({ ok: false, error: 'not-a-statement' }, 400);
|
|
283
|
+
if (hostOf(s.referrer) !== hostOf(url.hostname)) return json({ ok: false, error: 'not-ours', message: 'this statement names another referrer' }, 400);
|
|
284
|
+
let seller = null;
|
|
285
|
+
try { const u = new URL(String(s.seller)); seller = u.protocol === 'https:' && hostOf(u.hostname) ? u.origin : null; } catch { seller = null; }
|
|
286
|
+
if (!seller || !/^\d{4}-\d{2}$/.test(String(s.period ?? ''))) return json({ ok: false, error: 'bad-statement' }, 400);
|
|
287
|
+
let x = null;
|
|
288
|
+
try {
|
|
289
|
+
const res = await fetcher(`${seller}/.well-known/homie-studio.json`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
|
|
290
|
+
x = res.ok ? (await res.json())?.referrals?.key?.x ?? null : null;
|
|
291
|
+
} catch { x = null; }
|
|
292
|
+
if (!x || x !== env0.key || !(await verifyStatement(env0, x))) return json({ ok: false, error: 'signature', message: 'the signature does not match the key in the seller\'s own manifest' }, 401);
|
|
293
|
+
const t = s.totals ?? {};
|
|
294
|
+
try {
|
|
295
|
+
await env.DB.prepare('INSERT INTO referral_statements (seller, period, owed, pending, currency, body, received_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(seller, period) DO UPDATE SET owed = excluded.owed, pending = excluded.pending, currency = excluded.currency, body = excluded.body, received_at = excluded.received_at')
|
|
296
|
+
.bind(seller, s.period, Math.max(0, Math.floor(Number(t.due ?? t.owed) || 0)), Math.max(0, Math.floor(Number(t.pending) || 0)), String(s.currency ?? 'usd').slice(0, 3), text.slice(0, 65_536), Date.now()).run();
|
|
297
|
+
} catch { return json({ ok: false, error: 'not-migrated', message: 'this studio has no referral tables yet (migration 0008)' }, 503); }
|
|
298
|
+
return json({ ok: true, seller, period: s.period });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The statements other studios sent this one: what each says it owes. */
|
|
302
|
+
export async function statementsIn(env) {
|
|
303
|
+
try {
|
|
304
|
+
return ((await env.DB.prepare('SELECT seller, period, owed, pending, currency, received_at FROM referral_statements ORDER BY received_at DESC LIMIT 200').all()).results ?? [])
|
|
305
|
+
.map((r) => ({ seller: r.seller, period: r.period, due: Number(r.owed), pending: Number(r.pending), currency: r.currency, receivedAt: Number(r.received_at) }));
|
|
306
|
+
} catch { return []; }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Send each referrer its statement for `period` (to the address in its own manifest). */
|
|
310
|
+
export async function sendStatements(env, origin, shop, period, { fetcher = fetch } = {}) {
|
|
311
|
+
const books = await booksOf(env, { period });
|
|
312
|
+
const sent = [];
|
|
313
|
+
for (const book of books) {
|
|
314
|
+
const env0 = await statementFor(env, origin, book, period, shop?.referrals);
|
|
315
|
+
let to = null;
|
|
316
|
+
try {
|
|
317
|
+
const m = await (await fetcher(`https://${book.via}/.well-known/homie-studio.json`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(5000) })).json();
|
|
318
|
+
const st = String(m?.referrals?.statements ?? '');
|
|
319
|
+
to = st.startsWith(`https://${book.via}/`) ? st : null;
|
|
320
|
+
} catch { to = null; }
|
|
321
|
+
if (!to) { sent.push({ via: book.via, ok: false, why: 'no statement address in its manifest' }); continue; }
|
|
322
|
+
try {
|
|
323
|
+
const res = await fetcher(to, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(env0), signal: AbortSignal.timeout(8000) });
|
|
324
|
+
sent.push({ via: book.via, ok: res.ok, status: res.status, due: book.due });
|
|
325
|
+
} catch (error) { sent.push({ via: book.via, ok: false, why: String(error?.message ?? error).slice(0, 80) }); }
|
|
326
|
+
}
|
|
327
|
+
return sent;
|
|
328
|
+
}
|
package/worker/room.mjs
CHANGED
|
@@ -319,6 +319,8 @@ export class NetRoom {
|
|
|
319
319
|
watch: conn.watch === true, policy: WATCH_POLICIES.includes(conn.watchPolicy) ? conn.watchPolicy : 'follow', follow: null, followWhy: null,
|
|
320
320
|
// An agent (section 17): only the Worker's word, from a verified pass, makes one; its hello cannot.
|
|
321
321
|
agentWord: conn.agent && typeof conn.agent === 'object' ? conn.agent : null, agent: null, caps: new Set(), speechAt: [],
|
|
322
|
+
// A badge the studio's Worker verified (the shop: a supporter's); a hello cannot set one.
|
|
323
|
+
badge: typeof conn.badge === 'string' && conn.badge.length <= 16 ? conn.badge : null,
|
|
322
324
|
// Revision 7: the latest view the host showed this AI (what its arguments are checked against), and its pace.
|
|
323
325
|
lastView: null, viewAt: 0, doAt: 0,
|
|
324
326
|
};
|
|
@@ -375,7 +377,7 @@ export class NetRoom {
|
|
|
375
377
|
host() { return this.hostId ? this.clients.get(this.hostId) ?? null : null; }
|
|
376
378
|
hostRef() { const h = this.host(); return h ? { id: h.id, seat: h.seat } : null; }
|
|
377
379
|
roleOf(c) { return c.id === this.hostId ? 'host' : c.seat === null ? 'screen' : 'replica'; }
|
|
378
|
-
peer(c) { return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: { ...c.agent } } : {}), ...(this.muteOf(c) ? { muted: true } : {}) }; }
|
|
380
|
+
peer(c) { return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: { ...c.agent } } : {}), ...(this.muteOf(c) ? { muted: true } : {}), ...(c.badge && !c.agent ? { badge: c.badge } : {}) }; }
|
|
379
381
|
/** A hands-`host` agent (section 17): no game client of its own, so no snapshots, checkpoints or state. */
|
|
380
382
|
lite(c) { return Boolean(c.agent && c.agent.hands === 'host'); }
|
|
381
383
|
seatedHumans() { return this.live().filter((c) => c.seat !== null && !c.agent); }
|