@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.
@@ -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><button type="button" data-copy-link>Copy link</button></div>${g.saves ? `
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>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script><script>${CHAT_OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
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
  /*
@@ -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); }