@homie-rocks/studio 0.23.0 → 0.24.2

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/worker/index.mjs CHANGED
@@ -54,6 +54,10 @@
54
54
  * /<game>/api/chat/report a player reports one chat line (POST, this site's pages): the room's own copy of it is kept
55
55
  * for the owner, 30 days (worker/chat-store.mjs); room chat itself rides the sockets above
56
56
  * (NETPLAY.md section 19) and is never stored
57
+ * /shop/, /api/shop/... the studio's shop (worker/shop.mjs, shop/SHOP.md): its items in real money, the studio's
58
+ * OWN Stripe Checkout, the signed webhook, what a player owns (/api/player/owns), refunds;
59
+ * closed until the owner's restricted key is in; never on a kids server or a television
60
+ * /api/referrals/statement another studio's signed referral statement to this one (worker/referrals.mjs)
57
61
  *
58
62
  * A page in the studio's site/pages wins over the generated one at the same address. Every HTML answer is
59
63
  * `no-transform` (an edge in front of a custom domain injects nothing) and is never framed by another site.
@@ -104,6 +108,8 @@ import {
104
108
  // every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
105
109
  usePlayers(playerAccounts);
106
110
  import { STUDIO_VERSION_TAG } from './version.mjs';
111
+ import { roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
112
+ import { arrivalCookie, manifestReferrals } from './referrals.mjs';
107
113
  import { licenseOf, remixAllowed, remixRow } from './license.mjs';
108
114
 
109
115
  export { SEAT_MAX } from './seats.mjs';
@@ -461,6 +467,9 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
461
467
  ...(meta?.movement ? { movement: meta.movement } : {}),
462
468
  // game.json "saves": the play shell around this frame answers @homie-rocks/studio/saves (saves/SAVES.md).
463
469
  ...(meta?.saves && want === 'play' ? { saves: true } : {}),
470
+ // The studio sells something in this game (shop/SHOP.md): the play shell answers @homie-rocks/studio/shop. A
471
+ // watcher has no shop; the shell itself says "kids" on a kids server and shows only a code on a television.
472
+ ...(!agent && !watching && shellShop(cat, game) ? { shop: true } : {}),
464
473
  };
465
474
  let html = await res.text();
466
475
  const head = `<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
@@ -539,7 +548,18 @@ function finish(res, path) {
539
548
 
540
549
  export default {
541
550
  async fetch(request, env, ctx) {
542
- return finish(await route(request, env, ctx), new URL(request.url).pathname);
551
+ const url = new URL(request.url);
552
+ const res = finish(await route(request, env, ctx), url.pathname);
553
+ // A referral's arrival (worker/referrals.mjs): only a person's page load with ?via= of another site, on a studio
554
+ // that pays referrals. The page is answered first; a cookie is added to it only then.
555
+ if (url.searchParams.has('via') && res && res.status === 200 && /text\/html/i.test(res.headers.get('content-type') ?? '')) {
556
+ try {
557
+ const cat = await catalogue(env, url.origin);
558
+ const cookie = await arrivalCookie(request, env, url, shopOf(cat));
559
+ if (cookie) { const out = new Response(res.body, res); out.headers.append('set-cookie', cookie); return out; }
560
+ } catch { /* an arrival is never worth a failed page */ }
561
+ }
562
+ return res;
543
563
  },
544
564
  };
545
565
 
@@ -562,6 +582,11 @@ async function route(request, env, ctx) {
562
582
  }
563
583
  if (path.startsWith('/_studio/')) return (await officeRoutes(request, env, url, { catalogueOf: getAll })) ?? ownerRoutes(request, env, url, { catalogueOf: getAll });
564
584
  if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
585
+ // The shop (0.24.0) before player accounts: /api/player/owns is the shop's.
586
+ {
587
+ const shopped = await shopRoutes(request, env, ctx, url, { catalogueOf: getAll });
588
+ if (shopped) return shopped;
589
+ }
565
590
  if (path.startsWith('/api/player/') || path === '/account' || path === '/account/' || path === '/_homie/account.js') return playerRoutes(request, env, ctx, url, { catalogueOf: getCat });
566
591
  if (path === '/_homie/site.js') return new Response(SITE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
567
592
  if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
@@ -622,6 +647,10 @@ async function route(request, env, ctx) {
622
647
  ...(shareRooms ? { rooms: `${url.origin}/api/rooms` } : {}),
623
648
  // Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
624
649
  ...(played ? { played } : {}),
650
+ // The shop (0.24.0): whether it sells and its till, never a key or a sale; and referrals: this studio takes
651
+ // signed statements as a referrer, with the public half of its statement key and, if it sells, its terms.
652
+ ...(shopOf(cat)?.open ? { shop: { open: true, till: shopOf(cat).till, currency: shopOf(cat).currency, page: `${url.origin}/shop/` } } : {}),
653
+ ...(await manifestReferrals(env, url.origin, shopOf(cat), cat.studio).then((r) => (r ? { referrals: r } : {})).catch(() => ({}))),
625
654
  }, 200, { 'access-control-allow-origin': '*' });
626
655
  }
627
656
  if (path === '/api/games') {
@@ -885,11 +914,15 @@ async function route(request, env, ctx) {
885
914
  const local = isLocalOrigin(url.origin);
886
915
  let qr = null;
887
916
  if (!local) try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
888
- return playPage(cat, meta, { screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server });
917
+ // The shop on a television is a code to buy on a phone (the TV never sells; never on a kids server).
918
+ const sh = shellShop(cat, game, { kids: pol.kids });
919
+ let shopQr = null;
920
+ if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = null; }
921
+ return playPage(cat, meta, { screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null });
889
922
  }
890
923
  await countVisit(request, env, ctx, game, 'play');
891
924
  shareDaily(cat, url, ctx);
892
- return playPage(cat, meta, { ticket, owner: d.owner, launch, server, ...(await chatWho(env, game, srv, d.acct)) });
925
+ return playPage(cat, meta, { ticket, owner: d.owner, launch, server, ...(await chatWho(env, game, srv, d.acct)), shop: shellShop(cat, game, { kids: pol.kids }) });
893
926
  }
894
927
  if (sub === 'watch') {
895
928
  // The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
@@ -1005,8 +1038,12 @@ async function route(request, env, ctx) {
1005
1038
  const origin = request.headers.get('origin');
1006
1039
  const hub = sub === '__watch' && Boolean(origin) && origin !== 'null' && origin !== url.origin;
1007
1040
  const lean = { ...chat, emoji: chat.emoji.filter((e) => !REACTIONS.some((r) => r.k === e.k)) };
1041
+ // A supporter's badge (the shop, 0.24.0): what the player's account owns, looked up here, so a hello can never claim
1042
+ // one; none on a kids server, none for an AI or a watcher.
1043
+ const pid = !ag && !w && sub === '__net' ? parts.find((x) => x.startsWith('p-'))?.slice(2) : null;
1044
+ const badge = pid ? await roomBadge(env, cat, pid, { kids: pol.kids }) : null;
1008
1045
  const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
1009
- const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}`;
1046
+ const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}`;
1010
1047
  return stub.fetch(new Request(target, request));
1011
1048
  }
1012
1049
  // The same knock, relative to the game's own page: the same answer (see notAHomie).
@@ -1320,6 +1357,8 @@ export class Table {
1320
1357
  watch: url.searchParams.get('w') === '1',
1321
1358
  // An AI with a pass the Worker verified (section 17): its pass, role, hands and name. Only the Worker sets it.
1322
1359
  ...(agent && typeof agent === 'object' && url.pathname === '/__net' ? { agent } : {}),
1360
+ // A badge the player's account owns (the shop): the Worker looked it up; a hello cannot set one.
1361
+ ...(url.pathname === '/__net' && /^[\p{L}\p{N} .'&+-]{1,16}$/u.test(url.searchParams.get('bd') ?? '') ? { badge: url.searchParams.get('bd') } : {}),
1323
1362
  watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
1324
1363
  // House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
1325
1364
  qa: isQa(request),
@@ -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
  }