@homie-rocks/studio 0.51.1 → 0.51.3

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +15 -4
  3. package/app-starters/gathering/app.json +2 -44
  4. package/app-starters/gathering/index.html +1 -1
  5. package/app-starters/gathering/src/view.ts +6 -19
  6. package/apps/APPS.md +55 -1
  7. package/bin/homie-studio.mjs +12 -8
  8. package/guides/app/SKILL.md +22 -2
  9. package/guides/office/SKILL.md +9 -0
  10. package/guides/shop/SKILL.md +8 -0
  11. package/guides/studio-setup/SKILL.md +9 -1
  12. package/lib/app-needs.mjs +17 -2
  13. package/lib/app-preset-history.json +840 -0
  14. package/lib/app-ready.mjs +1 -0
  15. package/lib/art-cli.mjs +2 -2
  16. package/lib/art-tools.mjs +2 -2
  17. package/lib/build.mjs +4 -2
  18. package/lib/check.mjs +2 -0
  19. package/lib/cloudflare.mjs +19 -10
  20. package/lib/decisions.mjs +9 -5
  21. package/lib/dev.mjs +4 -1
  22. package/lib/directory.mjs +22 -5
  23. package/lib/giving-preset.mjs +9 -0
  24. package/lib/mcp-tools.mjs +1 -1
  25. package/lib/office.mjs +2 -1
  26. package/lib/scaffold.mjs +7 -4
  27. package/lib/shop-links.mjs +4 -2
  28. package/lib/shop-local.mjs +31 -0
  29. package/lib/shop.mjs +2 -1
  30. package/lib/style-presets.mjs +1 -0
  31. package/lib/template-history.json +92 -2
  32. package/lib/upgrade-app-presets.mjs +49 -0
  33. package/lib/upgrade.mjs +2 -1
  34. package/mcp/ui/build.js +3 -2
  35. package/package.json +1 -1
  36. package/shop/SHOP.md +6 -0
  37. package/site/SITE.md +13 -0
  38. package/tools/TOOLS.md +9 -0
  39. package/worker/app-records.mjs +3 -3
  40. package/worker/arrival.mjs +7 -5
  41. package/worker/build-id.mjs +1 -1
  42. package/worker/directory-choice.mjs +9 -0
  43. package/worker/index.mjs +25 -9
  44. package/worker/mcp.mjs +2 -2
  45. package/worker/pages.mjs +12 -5
  46. package/worker/records-page.mjs +1 -1
  47. package/worker/shop-page.mjs +2 -2
  48. package/worker/shop-subscriptions.mjs +42 -0
  49. package/worker/signin-target.mjs +8 -0
  50. package/worker/site.mjs +11 -7
  51. package/worker/stats-page.mjs +6 -3
  52. package/worker/version.mjs +1 -1
@@ -0,0 +1,49 @@
1
+ import {givingItems} from './giving-preset.mjs';
2
+ import {createHash} from 'node:crypto';
3
+ import {readFileSync,existsSync,readdirSync} from 'node:fs';
4
+ import {join} from 'node:path';
5
+ import {fileURLToPath} from 'node:url';
6
+ const history=JSON.parse(readFileSync(new URL('./app-preset-history.json',import.meta.url),'utf8'));
7
+ const presets=fileURLToPath(new URL('../app-starters/',import.meta.url));
8
+ const equal=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
9
+ const object=x=>x&&typeof x==='object'&&!Array.isArray(x);
10
+ // A three-way merge: only values still equal to the old preset take the new default.
11
+ function merge(mine,old,next) {
12
+ if(equal(mine,old))return next;
13
+ if(!object(mine)||!object(old)||!object(next))return mine;
14
+ const result={...mine};
15
+ for(const key of new Set([...Object.keys(old),...Object.keys(next)])) {
16
+ const value=merge(mine[key],old[key],next[key]);
17
+ if(value===undefined)delete result[key];else result[key]=value;
18
+ }
19
+ return result;
20
+ }
21
+ export function appPresetChanges(root) {
22
+ const changes=[];
23
+ const shopFile=join(root,'shop.json');
24
+ if(existsSync(shopFile)) {
25
+ const from=readFileSync(shopFile,'utf8');let shop;try{shop=JSON.parse(from);}catch{}
26
+ if(shop?.giving && equal(shop.items,givingItems([1000,2500,5000]))) {
27
+ changes.push({file:'shop.json',kind:'update-file',what:'refresh untouched giving choices; keep currency and receipt wording',from,text:JSON.stringify({...shop,items:givingItems()},null,2)+'\n'});
28
+ }
29
+ }
30
+ const apps=join(root,'apps');if(!existsSync(apps))return changes;
31
+ for(const entry of readdirSync(apps,{withFileTypes:true})) {
32
+ if(!entry.isDirectory())continue;
33
+ const rel=`apps/${entry.name}`,file=join(root,rel,'app.json');if(!existsSync(file))continue;
34
+ const text=readFileSync(file,'utf8');let mine;try{mine=JSON.parse(text);}catch{continue;}
35
+ const kind=mine.starter?.kind,old=history[kind];if(!old)continue;
36
+ const next=JSON.parse(readFileSync(join(presets,kind,'app.json'),'utf8'));
37
+ const merged=merge(mine,old.meta,next);merged.id=mine.id;merged.name=mine.name;
38
+ if(!equal(mine,merged))changes.push({file:rel+'/app.json',kind:'update-file',what:'refresh unchanged app preset defaults; keep studio edits',from:text,text:JSON.stringify(merged,null,2)+'\n'});
39
+ for(const [path,hash] of Object.entries(old.files)) {
40
+ if(path==='app.json'||!existsSync(join(root,rel,path))||!existsSync(join(presets,kind,path)))continue;
41
+ const from=readFileSync(join(root,rel,path),'utf8');
42
+ const normalized=from.replaceAll(`game: '${mine.id}'`,"game: 'welcome'");
43
+ if(createHash('sha256').update(normalized).digest('hex')!==hash)continue;
44
+ const want=readFileSync(join(presets,kind,path),'utf8').replaceAll("game: 'welcome'",`game: '${mine.id}'`);
45
+ if(from!==want)changes.push({file:rel+'/'+path,kind:'update-file',what:'refresh untouched app preset file',from,text:want});
46
+ }
47
+ }
48
+ return changes;
49
+ }
package/lib/upgrade.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {appPresetChanges} from './upgrade-app-presets.mjs';
1
2
  import {refreshWorkerConfig} from './worker-config.mjs';
2
3
  /**
3
4
  * `homie-studio upgrade [--apply] [--diff]` — an existing studio takes what this version's template adds, and
@@ -174,7 +175,7 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
174
175
  const who = { name, slug };
175
176
  const tmpl = studioFiles({ name, slug, homie: studio.homie?.directory ?? 'https://homie.rocks' });
176
177
  const { files: oldFiles, secs: oldSecs } = known(history, who);
177
- const changes = [];
178
+ const changes = appPresetChanges(root);
178
179
  const kept = [];
179
180
 
180
181
  // package.json: the pin, and the scripts the template's studios have.
package/mcp/ui/build.js CHANGED
@@ -70,9 +70,10 @@
70
70
  C.add(C.el('div', 'cb'), C.el('b', '', x.kind + ' ' + x.name), C.el('span', '', x.state + (x.plan ? ' · ' + x.plan : '')))));
71
71
  });
72
72
  card.appendChild(ul);
73
- if (p.cost) card.appendChild(C.el('p', 'note', p.cost));
73
+ if (p.cost) card.appendChild(C.el('p', 'note billing', p.cost));
74
74
  card.appendChild(C.el('p', 'fine', 'Cloudflare asks you to approve once in your browser if this computer is not signed in (a free account works).'));
75
- if (p.directory && p.directory.stores) card.appendChild(C.el('p', 'fine', 'The Homie directory stores ' + p.directory.stores));
75
+ if (p.directory && p.directory.choice) card.appendChild(C.el('p', 'directory-choice', p.directory.choice));
76
+ if (p.directory && p.directory.site && p.directory.stores) card.appendChild(C.el('p', 'fine', 'The Homie directory stores ' + p.directory.stores));
76
77
  if (p.never) card.appendChild(C.el('p', 'fine', p.never));
77
78
  return card;
78
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.51.1",
3
+ "version": "0.51.3",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/shop/SHOP.md CHANGED
@@ -333,3 +333,9 @@ Use Stripe's published CLI test fixtures/tokens to complete Checkout via API;
333
333
  never type card details into a form. Stop on an expired profile and ask the owner
334
334
  to approve `stripe login --project-name <profile>` once. Stop the adapter and remove
335
335
  dev bindings and disposable studios when finished.
336
+
337
+ ## Local Stripe test payments
338
+
339
+ See `apps/APPS.md`, “Real Stripe test checkout on this computer”, for the supported
340
+ `shop connect --local` and Stripe CLI forwarding recipe. Every paid monthly cycle
341
+ creates a paid order and `order.paid` with the subscription’s original app reference.
package/site/SITE.md CHANGED
@@ -591,3 +591,16 @@ credentials.
591
591
  X decides what it shows, may cache tags, and may change or stop this behavior.
592
592
  Local success does not prove that X will display a card. The studio can use the
593
593
  off switch above if it does not want to offer a game through a video-oriented card.
594
+
595
+
596
+ ## Canonical address and decorative app frames
597
+
598
+ Set `cloudflare.domain` for the canonical public address. The manifest uses that
599
+ address even when fetched from workers.dev; browser pages on the alias redirect,
600
+ while discovery and tool transports remain reachable. Listing requires an explicit
601
+ `homie.directory: true` or URL; existing claims remain valid.
602
+
603
+ An iframe inside `.hero-media` is decorative and lets the parent page scroll.
604
+ Add `data-interactive` only when people should interact with the hero. Ordinary app
605
+ frames permit Web Share and downloads; browser capability and a user gesture still
606
+ apply. App shells allow normal touch scrolling and text selection.
package/tools/TOOLS.md CHANGED
@@ -179,3 +179,12 @@ and removes secrets directly; remote owner tools `studio_events_webhook_status`
179
179
  and `studio_events_webhook_test` inspect delivery and test it. The receiver verifies
180
180
  Standard Webhooks signatures, deduplicates events, then wakes the owner's AI to use
181
181
  its own service connectors.
182
+
183
+
184
+ ## Signed-in connector address
185
+
186
+ Use `/mcp/auth` for owner and staff connectors. Its anonymous 401 includes the
187
+ `WWW-Authenticate` resource metadata URL for `/.well-known/oauth-protected-resource/mcp`.
188
+ Both transports use the same `/mcp` OAuth resource; `/mcp` remains public without a
189
+ token. An approved connection retains the account’s current app grants, not a copy
190
+ of its old permissions. Manage connections at `/_studio/office/connections`.
@@ -97,7 +97,7 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
97
97
  if (op !== 'read' && !sameOrigin(request, url) && !(appOriginOf(request) && !meta.roles[access.role].signIn)) return fail(403, 'same origin required');
98
98
  if (op === 'read') {
99
99
  const rows = id ? [await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3').bind(recordApp, collection, id).first()].filter(Boolean)
100
- : (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id LIMIT 1000').bind(recordApp, collection).all()).results;
100
+ : (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id').bind(recordApp, collection).all()).results;
101
101
  const records=[];
102
102
  for(const r of rows) {
103
103
  const record={id:r.id,data:JSON.parse(r.value),version:r.version,updatedAt:r.updated_at};
@@ -124,11 +124,11 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
124
124
  if (op !== 'delete') { const bad = recordProblem(meta.records.collections[collection], body.data, { create: op === 'create' }); if (bad) return fail(400, bad); }
125
125
  if (op !== 'create' && (!Number.isSafeInteger(body.version) || body.version < 1)) return fail(400, 'record version required');
126
126
  const stmt = op === 'create'
127
- ? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) SELECT ?1, ?2, ?3, ?4, 1, ?5 WHERE (SELECT COUNT(*) FROM app_records WHERE app = ?1 AND collection = ?2) < 1000').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now())
127
+ ? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) VALUES (?1, ?2, ?3, ?4, 1, ?5)').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now())
128
128
  : op === 'update' ? env.DB.prepare('UPDATE app_records SET value = ?4, version = version + 1, updated_at = ?5 WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?6').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now(), body.version)
129
129
  : env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(recordApp, collection, id, body.version);
130
130
  const result = await stmt.run();
131
- return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed, already exists, or collection full; reload');
131
+ return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed or already exists; reload');
132
132
  } catch (error) {
133
133
  if (/booking capacity|capacity below|slot has active/.test(error.message)) return fail(409, error.message);
134
134
  if (error instanceof SyntaxError || error.message === 'body too large') return fail(400, error.message);
@@ -1,3 +1,4 @@
1
+ import {appWords} from './app-format.mjs';
1
2
  /**
2
3
  * THE ARRIVAL (0.26.0): the play page and the big screen never open on a blank screen. From the page's first paint
3
4
  * until the game says it is playable, an arrival card covers the game's frame in the game's own look: its title and
@@ -66,19 +67,19 @@ export function arrivalCard(cat, g, { screen = false, off = false } = {}) {
66
67
  const pitch = String(L.pitch ?? '').trim() || firstSentence(g?.blurb);
67
68
  const studio = cat?.studio?.name ?? '';
68
69
  const keys = screen
69
- ? [['tv', L.controls?.tv ?? 'Phones are the controllers: scan the code to play.']]
70
+ ? [['tv', L.controls?.tv ?? (g.kind === 'app' ? appWords(g).join : 'Phones are the controllers: scan the code to play.')]]
70
71
  : [['phone', L.controls?.phone], ['desk', L.controls?.computer]].filter(([, v]) => v);
71
72
  const art = wide || tall
72
73
  ? `<picture class="arrive-art">${wide && tall ? `<source media="(min-aspect-ratio: 3/4)" srcset="${esc(wide)}">` : ''}<img src="${esc(tall ?? wide)}" alt="" decoding="async" fetchpriority="high" style="object-position:${esc(focus)}"></picture>`
73
74
  : '<div class="arrive-art bare" aria-hidden="true"></div>';
74
- const html = `<div class="arrive${screen ? ' big' : ''}" data-arrive data-from="${esc(c.from)}">
75
+ const html = `<div class="arrive${screen ? ' big' : ''}" data-arrive data-from="${esc(c.from)}"${g?.kind === 'app' ? ' data-app' : ''}>
75
76
  ${art}<div class="arrive-shade" aria-hidden="true"></div>
76
77
  <div class="arrive-in">
77
78
  ${studio ? `<p class="arrive-kicker">${esc(studio)}</p>` : ''}
78
79
  <h1 class="arrive-title">${esc(g?.name ?? '')}</h1>
79
80
  ${pitch ? `<p class="arrive-pitch">${esc(pitch)}</p>` : ''}
80
81
  <div class="arrive-meter" aria-hidden="true"><i data-arrive-bar></i></div>
81
- <p class="arrive-step" role="status" aria-live="polite"><span data-arrive-step>Finding a room…</span><span class="arrive-room" data-arrive-room></span></p>
82
+ <p class="arrive-step" role="status" aria-live="polite"><span data-arrive-step>${g?.kind === 'app' ? 'Opening the app…' : 'Finding a room…'}</span><span class="arrive-room" data-arrive-room></span></p>
82
83
  ${keys.map(([k, v]) => `<p class="arrive-keys" data-for="${k}">${esc(String(v).slice(0, 160))}</p>`).join('')}
83
84
  </div>
84
85
  </div>`;
@@ -165,9 +166,10 @@ export const ARRIVAL_JS = String.raw`(function () {
165
166
  function later(ms, why) { timers.push(setTimeout(function () { api.lift(why); }, ms)); }
166
167
  // Whatever happens, the game gets the screen within 30 s of the page opening.
167
168
  later(30000, 'timeout');
169
+ var app=el.getAttribute('data-app') !== null;
168
170
  var roomLabel = '';
169
171
  // A full server's wait ("you're next for a seat") stays said while its room loads.
170
- api.room = function (label) { roomLabel = label || ''; if (a.phase === 'wait') set('wait', a.step, 0.22); else set('game', 'Loading the game…', 0.22); if (!roomEl.textContent) roomEl.textContent = roomLabel; };
172
+ api.room = function (label) { roomLabel = label || ''; if (a.phase === 'wait') set('wait', a.step, 0.22); else set('game', app ? 'Opening the app…' : 'Loading the game…', 0.22); if (!roomEl.textContent) roomEl.textContent = roomLabel; };
171
173
  api.facts = function (text) { if (!api.done && text) roomEl.textContent = text; };
172
174
  api.full = function (text) { set('wait', text, 0.3); };
173
175
  var attachedAt = 0;
@@ -184,7 +186,7 @@ export const ARRIVAL_JS = String.raw`(function () {
184
186
  set('join', 'Joining ' + (roomLabel || 'the room') + '…', 0.36);
185
187
  later(15000, 'cap');
186
188
  } else if (m.what === 'role' || m.what === 'token') {
187
- set('world', 'Loading the world…', 0.5);
189
+ set('world', app ? 'Opening the app…' : 'Loading the world…', 0.5);
188
190
  // A game built with a helper older than the arrival says nothing more: it has the screen once it has a seat.
189
191
  if (a.mode === 'seat' && !seatedTimer) seatedTimer = setTimeout(function () { api.lift('seated'); }, 600);
190
192
  } else if (m.what === 'loading') {
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.51.1","sha256":"6919e263e0a419df6b77a79f359497fb74c099fdfec14d267dc3417413607e6e"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.51.3","sha256":"ac8867ee4a8f906ed64a106d28d60921552d1caad2d99dd70467ae42e70aea75"});
@@ -0,0 +1,9 @@
1
+ /** Only an explicit choice enables a new directory claim. */
2
+ export function directoryUrl(value) {
3
+ if (value === true) return 'https://homie.rocks';
4
+ if (typeof value !== 'string') return null;
5
+ try {
6
+ const url = new URL(value);
7
+ return url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname) ? url.origin : null;
8
+ } catch { return null; }
9
+ }
package/worker/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { directoryUrl } from './directory-choice.mjs';
1
2
  import {APP_STORAGE_JS} from './app-frame.mjs';
2
3
  import {recordsPage} from './records-page.mjs';
3
4
  import { RULES_WIRING_MESSAGE } from './rules-wiring.mjs';
@@ -235,13 +236,7 @@ const repoOf = (env) => { const r = String(env.HOMIE_REPO ?? '').trim(); return
235
236
  const repoSaid = new Map();
236
237
 
237
238
  export function directoryOf(cat) {
238
- const d = cat?.studio?.directory;
239
- if (d === null || d === false) return null;
240
- const at = String(d ?? 'https://homie.rocks').replace(/\/+$/, '');
241
- try {
242
- const u = new URL(at);
243
- return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(u.hostname)) ? u.origin : null;
244
- } catch { return null; }
239
+ return directoryUrl(cat?.studio?.directory);
245
240
  }
246
241
 
247
242
  /** This address's claim: kept, else asked of the directory now (and kept), else an older studio's single claim. */
@@ -251,6 +246,7 @@ async function claimOf(env, cat, url) {
251
246
  if (claimed.has(origin) && (!repo || repoSaid.get(origin) === repo)) return claimed.get(origin);
252
247
  const kept = await storedClaim(env, origin);
253
248
  if (kept?.own && (!repo || kept.repo === repo)) { claimed.set(origin, kept.own); if (repo) repoSaid.set(origin, repo); return kept.own; }
249
+ if (kept?.older) return kept.older;
254
250
  const directory = directoryOf(cat);
255
251
  const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
256
252
  const may = directory && env.DB && env.HOMIE_PREVIEW !== '1' && (url.protocol === 'https:' || (local && directory.startsWith('http://')))
@@ -543,7 +539,7 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
543
539
  'cache-control': 'no-store, no-transform',
544
540
  'access-control-allow-origin': '*',
545
541
  // The game runs in an opaque origin: it cannot read this site's storage or cookies.
546
- 'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups; frame-ancestors ${embedded ? `'self' ${embedAncestors(cat, url.origin, env.HOMIE_EMBED_PREVIEW === '1').replace("'none'", '')}` : frameAncestors(cat)}`,
542
+ 'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups${meta.kind === 'app' ? ' allow-downloads' : ''}; frame-ancestors ${embedded ? `'self' ${embedAncestors(cat, url.origin, env.HOMIE_EMBED_PREVIEW === '1').replace("'none'", '')}` : frameAncestors(cat)}`,
547
543
  },
548
544
  });
549
545
  }
@@ -611,9 +607,15 @@ function finish(res, path) {
611
607
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
612
608
  }
613
609
 
610
+ // Repeat after migration: an older isolate may recreate these during a rolling deploy.
611
+ async function removeReferralLeftovers(env) {
612
+ if(env.DB)await env.DB.prepare("DELETE FROM meta WHERE key GLOB 'referral_*' OR key GLOB 'referral-*'").run();
613
+ }
614
+
614
615
  export default {
615
616
  async scheduled(event, env, ctx) {
616
617
  const maintenance=(async () => {
618
+ await removeReferralLeftovers(env);
617
619
  const cat = await catalogue(env, 'https://studio.invalid');
618
620
  await notifyTools(env,cat,cat.studio?.url??'https://studio.invalid').catch(error=>console.warn('Tool catalogue refresh:',error.message));
619
621
  await reconcileOrders(env, shopOf(cat));
@@ -623,6 +625,7 @@ export default {
623
625
  await maintenance;
624
626
  },
625
627
  async fetch(request, env, ctx) {
628
+ await removeReferralLeftovers(env);
626
629
  const url = new URL(request.url);
627
630
  await (await import('./functions.mjs')).syncFunctions(env);
628
631
  const dispatch=()=>import('./functions.mjs').then(m=>m.runFunctions(env,()=>catalogue(env,url.origin),url.origin));
@@ -678,9 +681,18 @@ async function route(request, env, ctx) {
678
681
  const getAll = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
679
682
  const getCat = () => (pubP ??= Promise.all([getAll(), settingsOf(env)]).then(([cat, settings]) => publicCatalogue(cat, settings, env)));
680
683
 
684
+ // Keep tool transports and discovery on the alias; redirect browser pages only.
685
+ if (read && url.hostname.endsWith('.workers.dev') && env.HOMIE_PREVIEW !== '1'
686
+ && request.headers.get('upgrade')?.toLowerCase() !== 'websocket'
687
+ && !/\/(?:api|__[a-z]+)(?:\/|$)/.test(path)
688
+ && !/^\/(?:\.well-known(?:\/|$)|api(?:\/|$)|mcp(?:\/|$)|oauth(?:\/|$)|hooks(?:\/|$)|_studio(?:\/|$))/.test(path)) {
689
+ const canonical = (await getAll()).studio?.canonical;
690
+ if (canonical && canonical !== url.origin) return Response.redirect(canonical + url.pathname + url.search, 308);
691
+ }
692
+
681
693
  if (path === '/_studio/office/functions') return (await import('./functions.mjs')).functionsRoute(request,env,await getAll());
682
694
 
683
- if (path === '/mcp' || path.startsWith('/hooks/tools/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
695
+ if (path === '/mcp' || path === '/mcp/auth' || path.startsWith('/hooks/tools/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
684
696
  const { remoteMcp } = await import('./mcp.mjs');
685
697
  return remoteMcp(request, env, ctx, { catalogueOf: getAll, definitions: await studioTools() });
686
698
  }
@@ -726,6 +738,10 @@ async function route(request, env, ctx) {
726
738
  }
727
739
  if (path === '/.well-known/homie-studio.json') {
728
740
  const cat = await getCat();
741
+ const canonical = cat.studio?.canonical;
742
+ if (canonical && env.HOMIE_PREVIEW !== '1' && url.protocol === 'https:') {
743
+ const address = new URL(canonical); url.protocol = address.protocol; url.host = address.host;
744
+ }
729
745
  const sharing = Boolean(cat.studio?.stats?.share);
730
746
  const [played, byGame] = sharing ? await Promise.all([playedThisWeek(env), playedByGame(env)]) : [null, null];
731
747
  // studio.json `"rooms": { "share": false }` keeps the rooms off the hub: a manifest that names no `rooms` shares none.
package/worker/mcp.mjs CHANGED
@@ -158,7 +158,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
158
158
  });
159
159
  return s;
160
160
  };
161
- return createMcpHandler(server)(request,env,ctx);
161
+ return createMcpHandler(server,{route:path === '/mcp/auth' ? '/mcp/auth' : '/mcp'})(request,env,ctx);
162
162
  };
163
163
  if(localIdentity)return api(request,scoped,ctx);
164
164
  const provider=new OAuthProvider({
@@ -211,7 +211,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
211
211
  const rows=(await env.DB.prepare(owner?'SELECT c.*,p.name FROM mcp_connections c LEFT JOIN players p ON p.id=c.person ORDER BY c.created DESC LIMIT 100 OFFSET ?1':'SELECT c.*,p.name FROM mcp_connections c LEFT JOIN players p ON p.id=c.person WHERE c.person=?1 ORDER BY c.created DESC LIMIT 100 OFFSET ?2').bind(...(owner?[offset]:[person,offset])).all()).results;
212
212
  const events=owner?(await env.DB.prepare('SELECT a.*,p.name FROM mcp_audit a LEFT JOIN players p ON p.id=a.person ORDER BY a.id DESC LIMIT 100 OFFSET ?1').bind(offset).all()).results:[];
213
213
  const names=new Map(await Promise.all([...new Set([...rows,...events].map(r=>r.client).filter(Boolean))].map(async id=>[id,(await oauth.lookupClient(id).catch(()=>null))?.clientName??id])));
214
- return html(`<h1>Your studio's AI connections</h1><p>MCP address: <code>${esc(origin)}/mcp</code></p><ul>${rows.map(r=>`<li>${esc(r.name??(r.person.startsWith('office-')?'Owner browser':r.person))} · ${esc(names.get(r.client))} · ${r.revoked?'Revoked':`<form method="post"><input type="hidden" name="connection" value="${esc(r.id)}"><button>Revoke</button></form>`}</li>`).join('')}</ul><h2>Recent tool calls</h2><ul>${events.map(r=>`<li>${esc(new Date(r.at).toISOString())} · ${esc(r.name??(r.person?.startsWith('office-')?'Owner browser':r.person)??'Public')} · ${esc(names.get(r.client)??'Anonymous')} · ${esc(r.tool)} · ${esc(r.outcome)}</li>`).join('')}</ul><nav>${page?`<a href="?page=${page-1}">Newer</a>`:''}${rows.length===100||events.length===100?`<a href="?page=${page+1}">Older</a>`:''}</nav>`);
214
+ return html(`<h1>Your studio's AI connections</h1><p>Signed-in MCP address: <code>${esc(origin)}/mcp/auth</code> · Public: <code>${esc(origin)}/mcp</code></p><ul>${rows.map(r=>`<li>${esc(r.name??(r.person.startsWith('office-')?'Owner browser':r.person))} · ${esc(names.get(r.client))} · ${r.revoked?'Revoked':`<form method="post"><input type="hidden" name="connection" value="${esc(r.id)}"><button>Revoke</button></form>`}</li>`).join('')}</ul><h2>Recent tool calls</h2><ul>${events.map(r=>`<li>${esc(new Date(r.at).toISOString())} · ${esc(r.name??(r.person?.startsWith('office-')?'Owner browser':r.person)??'Public')} · ${esc(names.get(r.client)??'Anonymous')} · ${esc(r.tool)} · ${esc(r.outcome)}</li>`).join('')}</ul><nav>${page?`<a href="?page=${page-1}">Newer</a>`:''}${rows.length===100||events.length===100?`<a href="?page=${page+1}">Older</a>`:''}</nav>`);
215
215
  }
216
216
  return json({error:'not found'},404);
217
217
  }},
package/worker/pages.mjs CHANGED
@@ -125,7 +125,7 @@ export function playPage(cat, g, { embed = false, origin = '', preview = false,
125
125
  const shopControl = Boolean(shop && !screen && g.screen?.shop !== false);
126
126
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
127
127
  const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
128
- const places = sharePlaces(g.screen?.share);
128
+ const places = sharePlaces(g.screen?.share ?? (g.kind === 'app' ? 'top-right' : undefined));
129
129
  // The phone's glass belongs to the game: no page pan, pinch-zoom, text selection or callout under a thumb
130
130
  // (a pinch between a stick thumb and a button thumb made the browser cancel both touches).
131
131
  // A game with an art direction (games.json `ui`, from its style.json): the pills wear its paper and text, like its HUD.
@@ -200,6 +200,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
200
200
  .notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
201
201
  [hidden] { display: none !important; }
202
202
  ${arrive.css}
203
+ ${g.kind === 'app' ? 'html,body,iframe.game{touch-action:auto;user-select:auto;-webkit-user-select:auto;-webkit-touch-callout:default}' : ''}
203
204
  ${embed ? `html,body{touch-action:auto}.room.at-top-center{transform:none;left:auto;right:max(8px,env(safe-area-inset-right))}.embed-open{display:flex;align-items:center;min-height:44px;color:inherit}.embed-note{font:12px/1.4 system-ui;overflow-wrap:anywhere}.sheet{position:fixed;inset:calc(max(8px,env(safe-area-inset-top)) + 48px) max(8px,env(safe-area-inset-right)) auto auto;max-width:calc(100vw - max(8px,env(safe-area-inset-left)) - max(8px,env(safe-area-inset-right)));max-height:calc(100dvh - max(8px,env(safe-area-inset-top)) - 48px - max(8px,env(safe-area-inset-bottom)));overflow:auto;overscroll-behavior:contain;touch-action:pan-y}.sheet.more::after{content:'▾ more below';position:sticky;bottom:-12px;display:block;margin:0 -12px -12px;padding:16px 0 5px;text-align:center;font:600 11px/1 system-ui;color:#cfd6e6;background:linear-gradient(transparent,rgba(8,12,22,.98) 60%);pointer-events:none}.sheet .embed-help{margin:8px 2px;font:500 12px/1.4 system-ui;color:#aab3c7}html:not(.player-frame) .frame-only{display:none}.sheet a,.sheet button{min-height:44px}.sheet .foot a,.sheet .foot button{display:inline-flex;align-items:center}.embed-open .frame-only{margin-left:4px}.player-top iframe.game{inset:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);width:calc(100% - env(safe-area-inset-left) - env(safe-area-inset-right));height:calc(100% - env(safe-area-inset-top) - env(safe-area-inset-bottom))}.arrive-in{padding:12px}.arrive-title{font-size:clamp(24px,7vw,52px)}` : ''}
204
205
  ${shopControl ? `.shop-control{color:#eef1f8;font:600 13px/1.2 system-ui;position:fixed;top:env(safe-area-inset-top);left:max(8px,env(safe-area-inset-left));height:52px;display:flex;align-items:center;z-index:6}.shop-control button{min-height:44px;padding:0 18px}html body .room.room{flex-direction:column;align-items:flex-end;top:max(4px,env(safe-area-inset-top));right:max(8px,env(safe-area-inset-right));left:auto;bottom:auto;transform:none}html body iframe.game.game{top:calc(env(safe-area-inset-top) + 52px);height:calc(100% - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 52px)}` : ''}
205
206
  ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHOP_SHELL_CSS : ''}${pillUi}`;
@@ -241,7 +242,7 @@ ${embed ? ` <p class="embed-help">Sound starts after a press. If pointer lock
241
242
  </div>
242
243
  <div class="toast" data-toast role="status" hidden></div>`;
243
244
  return layoutless(`${g.name} · ${appWords(g).open}`, `
244
- <iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe>
245
+ <iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups${g.kind === 'app' ? ' allow-downloads' : ''}" allow="fullscreen *; autoplay *; gamepad *${g.kind === 'app' ? '; web-share *' : ''}"></iframe>
245
246
  ${arrive.html}
246
247
  <div class="chip${arrive.html ? ' held' : ''}" data-chip><span data-status>finding a room…</span></div>
247
248
  <div class="vote" data-vote role="dialog" aria-label="How strong should the AI be?" data-keep-focus hidden></div>
@@ -447,6 +448,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
447
448
  place();
448
449
  addEventListener('resize', place);
449
450
  function labelOf(room) {
451
+ if(boot.kind === 'app') return boot.name;
450
452
  var m = /^pub-(\d+)$/.exec(room);
451
453
  if (m) return 'Room ' + m[1];
452
454
  // A room of this page's server (s-<server>-<n>) is "Room <n>" too: the server pill beside the button names it.
@@ -815,6 +817,11 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
815
817
  }
816
818
 
817
819
  function paint() {
820
+ if(boot.kind === 'app') {
821
+ say(state.link?.state === 'reconnecting' ? 'Reconnecting…' : 'Connected');
822
+ var count=ui && ui.querySelector('[data-room-count]'); if(count)count.hidden=true;
823
+ serverUi(); return;
824
+ }
818
825
  var f = state.facts;
819
826
  var n = f && f.counts ? f.counts.players : null;
820
827
  var bits = [];
@@ -860,7 +867,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
860
867
  function serverUi() {
861
868
  if (!sToggle || !sSheet) return;
862
869
  var f = state.facts; var pol = f && f.policy ? f.policy : null;
863
- var show = Boolean(state.server) || (hasAi() && readsDial());
870
+ var show = boot.kind === 'app' ? Boolean(state.server && state.server.id !== 'public') : Boolean(state.server) || (hasAi() && readsDial());
864
871
  sToggle.hidden = !show;
865
872
  if (!show) return;
866
873
  var name = state.server ? state.server.name : 'Quick play';
@@ -1178,7 +1185,7 @@ export function watchPage(cat, g, { origin = '', room = null, ticket = null, pol
1178
1185
  <span class="eyes" data-eyes hidden title="Watching this room"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg><span data-eyes-n></span><span class="eyes-w"> watching</span></span>
1179
1186
  <a class="playb" data-playb href="${esc(openPath(g))}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4.8v14.4a1 1 0 0 0 1.5.86l12-7.2a1 1 0 0 0 0-1.72l-12-7.2A1 1 0 0 0 7 4.8Z" fill="currentColor"/></svg><span>${esc(appWords(g).open)}</span></a>
1180
1187
  </header>
1181
- <main class="stage"><iframe class="game" title="${esc(g.name)}, live" tabindex="-1" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe></main>
1188
+ <main class="stage"><iframe class="game" title="${esc(g.name)}, live" tabindex="-1" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups${g.kind === 'app' ? ' allow-downloads' : ''}" allow="fullscreen *; autoplay *; gamepad *${g.kind === 'app' ? '; web-share *' : ''}"></iframe></main>
1182
1189
  <footer class="dock">
1183
1190
  <div class="caption" data-caption role="status" aria-live="polite"><b data-cap-head>Joining the room…</b><span data-cap-sub></span></div>
1184
1191
  <nav class="strip" data-strip role="toolbar" aria-label="Whose view to watch"></nav>
@@ -1299,7 +1306,7 @@ const WATCH_JS = String.raw`${BUILD_RETRY_JS}(function () {
1299
1306
  }
1300
1307
  sizeUp();
1301
1308
  addEventListener('resize', sizeUp);
1302
- function labelOf(room) { var m = /^pub-(\d+)$/.exec(room || ''); return m ? 'Room ' + m[1] : (room || ''); }
1309
+ function labelOf(room) { if(boot.kind === 'app') return boot.name; var m = /^pub-(\d+)$/.exec(room || ''); return m ? 'Room ' + m[1] : (room || ''); }
1303
1310
  function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = String(text); return e; }
1304
1311
  function svg(path) { var s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor'); s.setAttribute('stroke-width', '2'); s.setAttribute('stroke-linecap', 'round'); s.setAttribute('stroke-linejoin', 'round'); s.innerHTML = path; return s; }
1305
1312
  var AUTO_ICON = '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6"/>';
@@ -9,5 +9,5 @@ export function recordsPage(meta,url,cat={},shop=null) {
9
9
  const tags=Object.entries(playerProperties(cat,meta,{origin:url.origin})).map(([name,content])=>`<meta name="${esc(name)}" content="${esc(content)}">`).join('');
10
10
  const boot={kind:'app',game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
11
11
  const source=`/${meta.id}/__game/?${params}`;
12
- return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(meta.name)}</title>${tags}<style>${SHOP_SHELL_CSS}html,body{margin:0;height:100%;background:#10151c;color:white;font:18px system-ui}iframe{border:0;width:100%;height:100%;display:block}[data-arrive]{position:fixed;inset:0;display:grid;place-items:center;background:#10151c}[data-arrive][hidden]{display:none;opacity:0;visibility:hidden}</style></head><body><div data-arrive>Opening ${esc(meta.name)}…</div><iframe class="game" title="${esc(meta.name)}" sandbox="allow-scripts allow-forms allow-modals allow-popups" allow="autoplay *; fullscreen *"></iframe><script>window.__HOMIE_APP=${JSON.stringify(boot).replace(/</g,'\\u003c')};window.__HOMIE_PLAY=window.__HOMIE_APP;window.__shell={room:new URLSearchParams(location.search).get('room')||'main',link:{state:'connecting'},arrival:{phase:'loading'}};</script><script>${APP_SHELL_JS}</script><script>${SHOP_SHELL_JS}</script><script>const frame=document.querySelector('iframe');frame.addEventListener('load',()=>{if(!frame.getAttribute('src'))return;requestAnimationFrame(()=>requestAnimationFrame(()=>{document.querySelector('[data-arrive]').hidden=true;window.__shell.link.state='online';window.__shell.arrival.phase='done';}));});frame.src=${JSON.stringify(source).replace(/</g,'\\u003c')};</script></body></html>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store, no-transform','x-frame-options':'SAMEORIGIN'}});
12
+ return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(meta.name)}</title>${tags}<style>${SHOP_SHELL_CSS}html,body{margin:0;height:100%;background:#10151c;color:white;font:18px system-ui}iframe{border:0;width:100%;height:100%;display:block}[data-arrive]{position:fixed;inset:0;display:grid;place-items:center;background:#10151c}[data-arrive][hidden]{display:none;opacity:0;visibility:hidden}</style></head><body><div data-arrive>Opening ${esc(meta.name)}…</div><iframe class="game" title="${esc(meta.name)}" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-downloads" allow="autoplay *; fullscreen *; web-share *"></iframe><script>window.__HOMIE_APP=${JSON.stringify(boot).replace(/</g,'\\u003c')};window.__HOMIE_PLAY=window.__HOMIE_APP;window.__shell={room:new URLSearchParams(location.search).get('room')||'main',link:{state:'connecting'},arrival:{phase:'loading'}};</script><script>${APP_SHELL_JS}</script><script>${SHOP_SHELL_JS}</script><script>const frame=document.querySelector('iframe');frame.addEventListener('load',()=>{if(!frame.getAttribute('src'))return;requestAnimationFrame(()=>requestAnimationFrame(()=>{document.querySelector('[data-arrive]').hidden=true;window.__shell.link.state='online';window.__shell.arrival.phase='done';}));});frame.src=${JSON.stringify(source).replace(/</g,'\\u003c')};</script></body></html>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store, no-transform','x-frame-options':'SAMEORIGIN'}});
13
13
  }
@@ -90,7 +90,7 @@ export function thanksPage(cat, shop, { session = null, game = null } = {}) {
90
90
  <p class="kicker">${esc(studio)}</p>
91
91
  <h1 data-thanks-title>Thank you</h1>
92
92
  <p class="lead" data-thanks-line>Stripe is confirming the payment. ${shop.giving ? 'Thank you for supporting our work.' : 'This page says when it is yours (a few seconds).'}</p>
93
- <p>${g ? `<a class="btn" href="/${esc(g.id)}/play">Back to ${esc(g.name)}</a>` : '<a class="btn" href="/">Back to the studio</a>'} <a href="/account/">Your account</a></p>
93
+ <p>${g ? `<a class="btn" href="/${esc(g.id)}/${g.kind === 'app' ? 'open' : 'play'}">Back to ${esc(g.name)}</a>` : `<a class="btn" href="/">Back to ${esc(studio)}</a>`} <a href="/account/">Your account</a></p>
94
94
  <p class="note">Your receipt comes from Stripe by email. ${shop.giving ? esc(shop.giving.receiptText ?? '') : refundWords(shop)}</p>
95
95
  </section>
96
96
  <script type="application/json" id="shop-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
@@ -298,7 +298,7 @@ export const SHOP_JS = String.raw`(function () {
298
298
  (function poll() {
299
299
  tries += 1;
300
300
  api('GET', '/api/shop/order?session=' + encodeURIComponent(boot.thanks)).then(function (r) {
301
- if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = boot.giving ? 'Thank you for your gift' : 'It\'s yours'; $('[data-thanks-line]').textContent = boot.giving ? 'Your gift is confirmed. Your receipt comes from Stripe by email.' : 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; if (r.simulated) { $('[data-thanks-title]').textContent = 'Simulation confirmed'; $('[data-thanks-line]').textContent = 'This was an offline test. No money moved and no Stripe receipt was sent.'; } return; }
301
+ if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = boot.giving ? 'Thank you for your gift' : 'It\'s yours'; $('[data-thanks-line]').textContent = boot.giving ? 'Your gift is confirmed.' : 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; if (r.simulated) { $('[data-thanks-title]').textContent = 'Simulation confirmed'; $('[data-thanks-line]').textContent = 'This was an offline test. No money moved and no Stripe receipt was sent.'; } return; }
302
302
  if (tries < 40) setTimeout(poll, 1500);
303
303
  else $('[data-thanks-line]').textContent = boot.giving ? 'Stripe has not confirmed your gift yet. Check your email for its receipt.' : 'Stripe has not confirmed it yet. It will be on your account as soon as it does; your receipt comes by email.';
304
304
  }).catch(function () { if (tries < 40) setTimeout(poll, 3000); });
@@ -31,6 +31,8 @@ export async function bindSubscription(env, order, session, shop) {
31
31
  env.DB.prepare(`INSERT OR IGNORE INTO shop_subscription_events(id,subscription,type,invoice,amount,currency,at)
32
32
  VALUES(?1,?2,'subscription.started',NULL,?3,?4,?5)`).bind(`started:${subscription}`,subscription,order.amount,order.currency,order.paid_at),
33
33
  ]);
34
+ const firstInvoice=idOf(session.invoice);
35
+ if(firstInvoice)await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO NOTHING').bind(`subscription-first-invoice:${subscription}`,firstInvoice).run();
34
36
  await reconcileMembership(env, subscription, shop);
35
37
  if(!hasStripeKey(env)) {
36
38
  const invoice=typeof session.invoice==='string'?session.invoice:session.invoice?.id;
@@ -70,6 +72,7 @@ export async function subscriptionEvent(env, event, shop) {
70
72
  else if (event.type === 'invoice.paid') { if (obj.billing_reason === 'subscription_create') return 'initial'; type='subscription.renewed'; state='active'; }
71
73
  else if (event.type === 'invoice.payment_failed') { type='subscription.failed'; state='past_due'; }
72
74
  else { return 'updated'; } // Scheduling cancellation is not cancellation; Stripe sends deleted when it ends.
75
+ if(type==='subscription.renewed')await recordRenewal(env,row,obj,at);
73
76
  const eventId=type === 'subscription.renewed' ? `paid:${obj.id}` : event.id;
74
77
  await env.DB.batch([
75
78
  env.DB.prepare(`UPDATE shop_subscriptions SET state=?2,event_at=?3 WHERE id=?1 AND state!='cancelled' AND event_at<=?3 AND ?4=0`).bind(subscription,state,at,hasStripeKey(env)?1:0),
@@ -196,3 +199,42 @@ async function invoiceMembership(env,subscription,invoice) {
196
199
  }
197
200
  if(writes.length)await env.DB.batch(writes);
198
201
  }
202
+
203
+ /** The invoice is the financial identity; delivery retries never count twice.
204
+ * Access stays on the original subscription; renewal orders are payment receipts. */
205
+ async function recordRenewal(env, subscription, invoice, at) {
206
+ if(invoice.status!=='paid')return;
207
+ if(await env.DB.prepare('SELECT id FROM shop_subscription_events WHERE id=?1').bind(`paid:${invoice.id}`).first())return;
208
+ const first=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`subscription-first-invoice:${subscription.id}`).first();
209
+ if(first?.value===invoice.id || invoice.billing_reason!=='subscription_cycle')return;
210
+ if(hasStripeKey(env)) {
211
+ const invoiceId=invoice.id;
212
+ invoice=await stripeCall(env,'GET',`/v1/invoices/${invoiceId}`,{expand:['lines.data.price.product']});
213
+ if(invoice.id!==invoiceId || idOf(invoice.parent?.subscription_details?.subscription??invoice.subscription)!==subscription.id || idOf(invoice.customer)!==subscription.customer || invoice.livemode!==(subscription.mode==='live'))throw new Error('Renewal invoice identity mismatch');
214
+ if(invoice.lines?.has_more)invoice.lines=await allPages(env,`/v1/invoices/${invoiceId}/lines`,{expand:['data.price.product']});
215
+ }
216
+ const original=await env.DB.prepare('SELECT * FROM shop_orders WHERE id=?1').bind(subscription.order_id).first();
217
+ const lines=(await env.DB.prepare('SELECT * FROM shop_order_lines WHERE order_id=?1').bind(original.id).all()).results;
218
+ // A subscription without shop lines has no shop fulfilment to repeat.
219
+ if(!lines.length)return;
220
+ if(invoice.status!=='paid' || invoice.lines?.has_more || invoice.currency!==original.currency || !Number.isSafeInteger(invoice.amount_paid) || invoice.amount_paid<0)throw new Error('Complete paid renewal invoice required');
221
+ const paidLines=(invoice.lines?.data??[]).map(l=>{
222
+ const price=idOf(l.price ?? l.pricing?.price_details?.price);
223
+ const product=l.price?.product;
224
+ const source=lines.find(s=>{const snapshot=JSON.parse(s.snapshot??'{}');return snapshot.stripePrice===price || hasStripeKey(env) && (product?.metadata?.line===s.id || [product?.metadata?.homie_item,product?.metadata?.item].includes(s.item) || snapshot.stripeProduct && snapshot.stripeProduct===idOf(product));});
225
+ if(!source || !Number.isSafeInteger(l.quantity) || l.quantity<1 || !Number.isSafeInteger(l.amount) || l.amount<0)throw new Error('Unknown renewal invoice line');
226
+ return {...source,quantity:l.quantity,amount:l.amount};
227
+ });
228
+ if(!paidLines.length)throw new Error('Renewal invoice has no lines');
229
+ const amount=paidLines.reduce((n,l)=>n+l.amount,0),total=invoice.total??invoice.amount_paid;
230
+ const id=`renewal:${invoice.id}`;
231
+ const writes=[env.DB.prepare(`INSERT OR IGNORE INTO shop_orders(id,player,item,game,amount,currency,total,till,mode,status,created_at,updated_at)
232
+ VALUES(?1,?2,?3,?4,?5,?6,?7,'stripe',?8,'started',?9,?9)`)
233
+ .bind(id,original.player,original.item,original.game,amount,original.currency,total,original.mode,at),
234
+ env.DB.prepare(`INSERT OR IGNORE INTO meta(key,value) SELECT ?1,value FROM meta WHERE key=?2`).bind(`shop-reference:${id}`,`shop-reference:${original.id}`),
235
+ ...paidLines.map((l,i)=>env.DB.prepare(`INSERT OR IGNORE INTO shop_order_lines(id,order_id,position,item,quantity,unit_amount,amount,total,snapshot,status)
236
+ VALUES(?1,?2,?3,?4,?5,?6,?7,?7,?8,'paid')`).bind(`${id}_${i}`,id,i,l.item,l.quantity,l.amount/l.quantity,l.amount,l.snapshot)),
237
+ env.DB.prepare("UPDATE shop_orders SET paid_at=?2,status='paid',payment=?3,tax=?4 WHERE id=?1 AND paid_at IS NULL")
238
+ .bind(id,at,idOf(invoice.payment_intent)??null,total-amount)];
239
+ await env.DB.batch(writes);
240
+ }
@@ -0,0 +1,8 @@
1
+ /** Same-site destinations, including app routes and query strings. */
2
+ export function signinTarget(value, fallback = '/_studio/stats') {
3
+ if (typeof value !== 'string' || !value.startsWith('/') || /^\/\//.test(value) || /[\\\u0000-\u0020]/.test(value)) return fallback;
4
+ try {
5
+ const target = new URL(value, 'https://studio.invalid');
6
+ return target.origin === 'https://studio.invalid' ? target.pathname + target.search + target.hash : fallback;
7
+ } catch { return fallback; }
8
+ }
package/worker/site.mjs CHANGED
@@ -107,6 +107,8 @@ img,video{display:block;max-width:100%}
107
107
  .hero-media{position:absolute;inset:0;z-index:-2;overflow:hidden;background:
108
108
  radial-gradient(60% 50% at 70% 30%,color-mix(in srgb,var(--glow) 26%,transparent),transparent 70%),
109
109
  radial-gradient(55% 45% at 25% 70%,color-mix(in srgb,var(--hot) 24%,transparent),transparent 70%),var(--bg)}
110
+ .hero-media iframe{pointer-events:none}
111
+ .hero-media iframe[data-interactive]{pointer-events:auto}
110
112
  .hero-media img,.hero-media video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:var(--focus,50% 50%)}
111
113
  /* The studio's own colour over the picture, so a bright cover still carries the words (game.json landing.hero.tint, 0 to 80). */
112
114
  .hero-media::before{content:"";position:absolute;inset:0;z-index:1;pointer-events:none;background:color-mix(in srgb,var(--bg) var(--tint,16%),transparent)}
@@ -554,14 +556,16 @@ const studioName = (cat) => cat.studio?.name ?? 'Studio';
554
556
  export function sectionsOf(cat) {
555
557
  const games = (cat.games ?? []).some((g) => g.kind !== 'app');
556
558
  const apps = (cat.games ?? []).some((g) => g.kind === 'app');
559
+ const ownMenu = ['business','cause'].includes(cat.studio?.kind);
557
560
  return [
558
- ...(cat.site?.pages ?? []).filter(p => /^\/[a-z0-9][a-z0-9._-]*\/$/.test(p) && !['/shop/', '/apps/', '/games/', '/rooms/', '/posts/', '/music/', '/videos/', '/lounge/'].includes(p)).map(p => ({ key: p, href: p, label: p.slice(1, -1).replaceAll('-', ' ').replace(/^./, c => c.toUpperCase()) })),
561
+ ...(cat.site?.pages ?? []).filter(p => /^\/[a-z0-9][a-z0-9._-]*\/$/.test(p) && !['/shop/', '/apps/', '/games/', '/rooms/', '/posts/', '/music/', '/videos/', '/lounge/'].includes(p)).map(p => ({ key: p, href: p, label: (ownMenu && (cat.games??[]).find(g=>g.kind==='app' && `/${g.id}/`===p)?.name) || p.slice(1, -1).replaceAll('-', ' ').replace(/^./, c => c.toUpperCase()) })),
559
562
  cat.shop && { key: 'shop', href: '/shop/', label: cat.shop.giving ? 'Give' : 'Shop' },
560
- apps && { key: 'apps', href: '/apps/', label: 'Apps' },
563
+ ...((ownMenu ? cat.games ?? [] : []).filter(g=>g.kind==='app' && !(cat.site?.pages??[]).includes(`/${g.id}/`)).map(g=>({key:g.id,href:`/${g.id}/`,label:g.name}))),
564
+ apps && !ownMenu && { key: 'apps', href: '/apps/', label: 'Apps' },
561
565
  games && { key: 'games', href: '/games/', label: 'Games' },
562
566
  (cat.songs ?? []).length > 0 && { key: 'music', href: '/music/', label: 'Music' },
563
567
  (cat.videos ?? []).length > 0 && { key: 'videos', href: '/videos/', label: 'Videos' },
564
- (games || apps) && { key: 'rooms', href: '/rooms/', label: 'Rooms' },
568
+ (games || (apps && !ownMenu)) && { key: 'rooms', href: '/rooms/', label: 'Rooms' },
565
569
  (cat.posts ?? []).length > 0 && { key: 'posts', href: '/posts/', label: 'Posts' },
566
570
  // The Lounge (0.29.0): the studio's own community room, when studio.json turns it on.
567
571
  cat.studio?.lounge && { key: 'lounge', href: '/lounge/', label: cat.studio.lounge.tab ?? 'Lounge' },
@@ -730,7 +734,7 @@ function gameCard(g, playing = 0) {
730
734
  <h3><a href="/${esc(g.id)}/">${esc(g.name)}</a></h3>
731
735
  <p>${esc(g.landing?.pitch ?? g.blurb)}</p>
732
736
  <div class="meta"><span>${esc(g.kind === 'app' ? 'One screen · live together' : playersText(g))}</span>${minutes(g.roundSeconds) ? `<span>${esc(minutes(g.roundSeconds))}</span>` : ''}<span>phone, computer or TV</span></div>
733
- <div class="acts"><a class="btn" href="${esc(openPath(g))}" data-play>${PLAY_ICON}${esc(appWords(g).open)}</a><a class="ghost" href="/${esc(g.id)}/">About</a></div>
737
+ <div class="acts"><a class="btn" href="${esc(openPath(g))}" data-play>${PLAY_ICON}${esc(g.kind === 'app' ? g.landing?.action ?? `Open ${g.name}` : appWords(g).open)}</a><a class="ghost" href="/${esc(g.id)}/">About</a></div>
734
738
  </div>
735
739
  </article>`;
736
740
  }
@@ -834,7 +838,7 @@ export function homeHero(cat, f, { playing = 0 } = {}) {
834
838
  <p class="kicker">${esc(name)}<span class="k-opt"> · ${esc(tagline || (f.kind === 'app' ? 'featured app' : 'featured game'))}</span></p>
835
839
  <h1 class="title ${titleClass(f.name)}" id="hero-title">${esc(f.name)}</h1>
836
840
  <p class="line">${esc(f.landing?.pitch ?? f.blurb)}</p>
837
- <div class="row"><a class="play" href="${esc(openPath(f))}" data-play>${PLAY_ICON}<span>${f.kind === 'app' ? esc(appWords(f).open) : 'Play now — free'}</span></a>${liveLine(f, playing, { idle: f.kind === 'app' ? 'One screen, together' : 'Bots hold every empty seat · press Play and you are in a round' })}</div>
841
+ <div class="row"><a class="play" href="${esc(openPath(f))}" data-play>${PLAY_ICON}<span>${f.kind === 'app' ? esc(f.landing?.action ?? `Open ${f.name}`) : 'Play now — free'}</span></a>${liveLine(f, playing, { idle: f.kind === 'app' ? 'One screen, together' : 'Bots hold every empty seat · press Play and you are in a round' })}</div>
838
842
  <a class="also" href="/${esc(f.id)}/">${icon('arrow')}<span>About ${esc(f.name)}</span></a>${games.length > 1 ? `<a class="also" href="/${f.kind === 'app' ? 'apps' : 'games'}/">${icon('games')}<span>All ${games.length} ${f.kind === 'app' ? 'apps' : 'games'}</span></a>` : ''}
839
843
  </div>
840
844
  </section>`;
@@ -1197,7 +1201,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1197
1201
  ${lineage ? `<p class="based-on">Based on ${ofWhat}</p>` : ''}
1198
1202
  <p class="line">${esc(L.pitch ?? g.blurb)}</p>
1199
1203
  <div class="row">
1200
- <a class="play" href="${esc(openPath(g))}" data-play>${PLAY_ICON}<span>${app ? esc(words.open) : 'Play now — free'}</span></a>
1204
+ <a class="play" href="${esc(openPath(g))}" data-play>${PLAY_ICON}<span>${app ? esc(L.action ?? `Open ${g.name}`) : 'Play now — free'}</span></a>
1201
1205
  ${liveLine(g, playing, { idle: app ? 'One screen, live together' : `Bots hold every empty seat · one tap and you are in` })}
1202
1206
  </div>
1203
1207
  ${tvOn ? `<a class="also" href="#anywhere">${icon('tv')}<span>Or put it on the big screen</span></a>` : ''}${playing > 0 && rooms.length && watchOf(g) !== 'off' ? `<a class="also" href="/${esc(g.id)}/watch">${icon('eye')}<span>Watch a live room</span></a>` : ''}${trailer ? `<a class="also" href="/videos/${esc(trailer.slug)}/">${icon('videos')}<span>Watch the trailer</span></a>` : ''}${g.saves ? `<a class="also" href="/account/?next=${encodeURIComponent(`/${g.id}/play`)}" data-account>${icon('spark')}<span>Your progress follows you: sign in</span></a>` : ''}
@@ -1268,7 +1272,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1268
1272
  head: `${h.tallImage || h.wideImage ? `<link rel="preload" as="image" href="${esc(h.tallImage ?? h.wideImage)}"${h.tallImage && h.wideImage ? ' media="(max-aspect-ratio: 3/4)"' : ''}>${h.tallImage && h.wideImage ? `<link rel="preload" as="image" href="${esc(h.wideImage)}" media="(min-aspect-ratio: 3/4)">` : ''}` : ''}`,
1269
1273
  // A game that is not public yet (private, or an invite-only beta) is never indexed, and offers nothing.
1270
1274
  ld: landingLd(cat, g, origin, { listed, shop }), ...(listed ? {} : { extraHeaders: { 'x-robots-tag': 'noindex' } }),
1271
- main: `${hero}${app ? `<section class="band"><div class="band-in"><h2>One screen. Your place in it.</h2><p>${esc(L.about ?? g.blurb)}</p><div class="keys"><a class="btn" href="${esc(openPath(g))}">${esc(words.open)}</a><a class="ghost" href="/${esc(g.id)}/tv">Wall screen</a></div>${qr ? `<div class="scan"><span class="qr">${qr}</span><p>${esc(words.join)}</p></div>` : ''}</div></section>` : ways + liveBand}${servers ? serversBand(g, servers) : ''}${app ? '' : howBand}${shotsBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}`,
1275
+ main: `${hero}${app ? `<section class="band"><div class="band-in"><h2>One screen. Your place in it.</h2><p>${esc(L.about ?? g.blurb)}</p><div class="keys"><a class="btn" href="${esc(openPath(g))}">${esc(L.action ?? `Open ${g.name}`)}</a><a class="ghost" href="/${esc(g.id)}/tv">Wall screen</a></div>${qr ? `<div class="scan"><span class="qr">${qr}</span><p>${esc(words.join)}</p></div>` : ''}</div></section>` : ways + liveBand}${servers ? serversBand(g, servers) : ''}${app ? '' : howBand}${shotsBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}`,
1272
1276
  });
1273
1277
  }
1274
1278