@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.
- package/CHANGELOG.md +45 -0
- package/README.md +15 -4
- package/app-starters/gathering/app.json +2 -44
- package/app-starters/gathering/index.html +1 -1
- package/app-starters/gathering/src/view.ts +6 -19
- package/apps/APPS.md +55 -1
- package/bin/homie-studio.mjs +12 -8
- package/guides/app/SKILL.md +22 -2
- package/guides/office/SKILL.md +9 -0
- package/guides/shop/SKILL.md +8 -0
- package/guides/studio-setup/SKILL.md +9 -1
- package/lib/app-needs.mjs +17 -2
- package/lib/app-preset-history.json +840 -0
- package/lib/app-ready.mjs +1 -0
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +2 -2
- package/lib/build.mjs +4 -2
- package/lib/check.mjs +2 -0
- package/lib/cloudflare.mjs +19 -10
- package/lib/decisions.mjs +9 -5
- package/lib/dev.mjs +4 -1
- package/lib/directory.mjs +22 -5
- package/lib/giving-preset.mjs +9 -0
- package/lib/mcp-tools.mjs +1 -1
- package/lib/office.mjs +2 -1
- package/lib/scaffold.mjs +7 -4
- package/lib/shop-links.mjs +4 -2
- package/lib/shop-local.mjs +31 -0
- package/lib/shop.mjs +2 -1
- package/lib/style-presets.mjs +1 -0
- package/lib/template-history.json +92 -2
- package/lib/upgrade-app-presets.mjs +49 -0
- package/lib/upgrade.mjs +2 -1
- package/mcp/ui/build.js +3 -2
- package/package.json +1 -1
- package/shop/SHOP.md +6 -0
- package/site/SITE.md +13 -0
- package/tools/TOOLS.md +9 -0
- package/worker/app-records.mjs +3 -3
- package/worker/arrival.mjs +7 -5
- package/worker/build-id.mjs +1 -1
- package/worker/directory-choice.mjs +9 -0
- package/worker/index.mjs +25 -9
- package/worker/mcp.mjs +2 -2
- package/worker/pages.mjs +12 -5
- package/worker/records-page.mjs +1 -1
- package/worker/shop-page.mjs +2 -2
- package/worker/shop-subscriptions.mjs +42 -0
- package/worker/signin-target.mjs +8 -0
- package/worker/site.mjs +11 -7
- package/worker/stats-page.mjs +6 -3
- 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.
|
|
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.
|
|
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`.
|
package/worker/app-records.mjs
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
|
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);
|
package/worker/arrival.mjs
CHANGED
|
@@ -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
|
|
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') {
|
package/worker/build-id.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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"/>';
|
package/worker/records-page.mjs
CHANGED
|
@@ -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
|
}
|
package/worker/shop-page.mjs
CHANGED
|
@@ -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)}
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|