@homie-rocks/studio 0.36.0 → 0.37.1
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 +34 -0
- package/README.md +4 -2
- package/app-starters/welcome/app.json +106 -0
- package/app-starters/welcome/index.html +4 -0
- package/app-starters/welcome/src/main.ts +107 -0
- package/apps/APPS.md +61 -0
- package/apps/apps.ts +28 -0
- package/bin/homie-studio.mjs +19 -4
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +6 -6
- package/lib/asset-check.mjs +3 -2
- package/lib/asset-import.mjs +3 -2
- package/lib/asset-manifest.mjs +8 -8
- package/lib/build.mjs +6 -3
- package/lib/characters.mjs +7 -7
- package/lib/check.mjs +49 -0
- package/lib/cloudflare.mjs +13 -4
- package/lib/codex.mjs +2 -2
- package/lib/collision.mjs +1 -1
- package/lib/decisions.mjs +7 -7
- package/lib/dev.mjs +26 -9
- package/lib/directory.mjs +1 -1
- package/lib/doctor.mjs +8 -8
- package/lib/domain.mjs +43 -0
- package/lib/handoff.mjs +1 -1
- package/lib/jobs.mjs +17 -7
- package/lib/lab-check.mjs +1 -1
- package/lib/lab.mjs +8 -8
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +71 -21
- package/lib/mcp.mjs +6 -11
- package/lib/parts-build.mjs +4 -3
- package/lib/parts-cli.mjs +3 -3
- package/lib/parts-store.mjs +8 -7
- package/lib/parts-tools.mjs +7 -7
- package/lib/parts.mjs +19 -14
- package/lib/perf.mjs +2 -1
- package/lib/player-image.mjs +2 -2
- package/lib/prepare.mjs +47 -0
- package/lib/scaffold.mjs +31 -2
- package/lib/shop.mjs +1 -1
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +7 -2
- package/lib/stats.mjs +2 -1
- package/lib/studio.mjs +32 -6
- package/lib/style-board.mjs +10 -9
- package/lib/template-history.json +82 -0
- package/links/links.d.ts +3 -0
- package/links/links.mjs +16 -0
- package/package.json +14 -3
- package/parts/PARTS.md +10 -5
- package/shop/SHOP.md +13 -0
- package/site/SITE.md +1 -1
- package/worker/account-page.mjs +4 -3
- package/worker/app-format.mjs +42 -0
- package/worker/app-records.mjs +100 -0
- package/worker/app-shell.mjs +29 -0
- package/worker/embed.mjs +4 -2
- package/worker/index.mjs +33 -9
- package/worker/pages.mjs +33 -27
- package/worker/parts.mjs +11 -9
- package/worker/schema.mjs +6 -5
- package/worker/seats.mjs +2 -2
- package/worker/shop-page.mjs +4 -3
- package/worker/site.mjs +49 -40
- package/worker/standalone.mjs +2 -2
- package/worker/version.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.1",
|
|
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",
|
|
@@ -75,7 +75,15 @@
|
|
|
75
75
|
"default": "./rules/host.ts"
|
|
76
76
|
},
|
|
77
77
|
"./worker": "./worker/index.mjs",
|
|
78
|
-
"./package.json": "./package.json"
|
|
78
|
+
"./package.json": "./package.json",
|
|
79
|
+
"./links": {
|
|
80
|
+
"types": "./links/links.d.ts",
|
|
81
|
+
"default": "./links/links.mjs"
|
|
82
|
+
},
|
|
83
|
+
"./apps": {
|
|
84
|
+
"types": "./apps/apps.ts",
|
|
85
|
+
"default": "./apps/apps.ts"
|
|
86
|
+
}
|
|
79
87
|
},
|
|
80
88
|
"files": [
|
|
81
89
|
"bin/",
|
|
@@ -99,7 +107,10 @@
|
|
|
99
107
|
"README.md",
|
|
100
108
|
"CHANGELOG.md",
|
|
101
109
|
"LICENSE",
|
|
102
|
-
"NOTICE"
|
|
110
|
+
"NOTICE",
|
|
111
|
+
"links/",
|
|
112
|
+
"apps/",
|
|
113
|
+
"app-starters/"
|
|
103
114
|
],
|
|
104
115
|
"engines": {
|
|
105
116
|
"node": ">=22"
|
package/parts/PARTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Studio parts
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Studios build on each other by sharing **parts**: a reusable piece of a game, app, music or video that its studio chooses to share, so somebody
|
|
4
4
|
making another game can use it. A creature from one game, a level from another, a bot brain from a third. That is
|
|
5
5
|
the whole idea. Without parts, a creator's AI has to come up with everything on its own.
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ Mashing up is not a command: it is making a game with parts from several games.
|
|
|
8
8
|
|
|
9
9
|
## The rules
|
|
10
10
|
|
|
11
|
-
1. **A part is a piece
|
|
11
|
+
1. **A part is a reusable piece, never a whole game or app.** Remix, which handed over an entire game, is retired;
|
|
12
12
|
parts are what replaces it.
|
|
13
13
|
2. **A part can take any form** that suits the piece: code, assets, data, a JSON description, a tuned config, or a
|
|
14
14
|
mix. Form is not restricted.
|
|
@@ -89,10 +89,11 @@ named after it):
|
|
|
89
89
|
| Field | What it is |
|
|
90
90
|
| --- | --- |
|
|
91
91
|
| `id`, `name`, `kind`, `version`, `summary` | `version` is three numbers; a shared version never changes, so a change is a new version. `summary` is one sentence. |
|
|
92
|
-
| `kind` | `character`, `rig`, `clips`, `environment`, `effect`, `sound`, `ui`, `mechanic`, `shader`, `level-generator`, `bot-brain`, `audio-pack`, `set-piece`. There is no kind for a whole game. |
|
|
92
|
+
| `kind` | `character`, `rig`, `clips`, `environment`, `effect`, `sound`, `ui`, `mechanic`, `shader`, `level-generator`, `bot-brain`, `audio-pack`, `set-piece`. `quiz`, `menu-board`, `waitlist`, `live-total`, `loop`, `stem`, `track`, `video-intro`, `video-template` also name parts. There is no kind for a whole game or app. |
|
|
93
93
|
| `license`, `attribution` | Needed to share: an SPDX identifier, and who a game using the part credits. |
|
|
94
94
|
| `share` | **Default `false`.** The part's own switch, and the only thing that decides whether it is shared. |
|
|
95
|
-
| `
|
|
95
|
+
| `uses` | Optional intended uses: `game`, `app`, `venue`, `cause`, `music`, `video`. Describe the purpose, independently of licence and any future pricing. |
|
|
96
|
+
| `from` | The source and studio: `game`, `app`, `music` or `video` with its id. Written when a part is lifted out of a game. |
|
|
96
97
|
| `entry` | For a part with code: the module a game imports. A part of assets or data has none. |
|
|
97
98
|
| `files` | Every file with its SHA-256 and size, **written by the tool, never by hand**. A file may carry `"rights"` (below). |
|
|
98
99
|
| `preview` | `page`: a self-contained HTML page shown in a sandboxed frame. `image`: a still. `aspect`: the frame's shape as `"W:H"` (whole numbers 1 to 32, no flatter than 3:1, no taller than 1:2; default `"16:9"`). `phoneAspect`: the shape when the frame is narrower than 600 px. |
|
|
@@ -167,3 +168,7 @@ build, whether or not the add named the game.
|
|
|
167
168
|
A package the studio's own `package.json` already names at a version npm says does not fit is explained and left
|
|
168
169
|
alone.
|
|
169
170
|
- The plumbing behind the tools is `homie-studio parts find | new | add | share | unshare | check`.
|
|
171
|
+
|
|
172
|
+
App parts use the same lift/import/credit paths as game parts. Music and video parts can start empty with the corresponding kind; add their selected files, licence and provenance before sharing. `uses` describes what they are for, not permission to use them: the licence controls that. No price or checkout behavior is introduced; future commercial metadata can be added separately without changing the meaning of `share`.
|
|
173
|
+
|
|
174
|
+
For example: `homie-studio parts new welcome-queue --kind waitlist --uses app,venue`, or `parts new gala-loop --kind loop --uses venue,cause`. A source app is selected by the existing `--from <id>` and `--game <id>` options.
|
package/shop/SHOP.md
CHANGED
|
@@ -201,3 +201,16 @@ Game and TV checkout use the same tab if a new tab is unavailable, then return t
|
|
|
201
201
|
During rollback, the released Worker grants only a cart’s first item until the new Worker repairs it; it
|
|
202
202
|
allows whole-cart player refunds even when a tip is included, and counts partially refunded carts at
|
|
203
203
|
the full amount toward a cap. The additive schema preserves its original rows and entitlement keys.
|
|
204
|
+
|
|
205
|
+
## Shop button on the screen
|
|
206
|
+
|
|
207
|
+
An open shop with items for this game or app shows **Shop** above its play/open screen. The shell reserves 52 pixels plus the safe area above the content, so the button never covers its controls. In `game.json` or `app.json`, set `"screen": { "shop": false }` to hide this button (merge with any existing screen settings). The room panel and your own shop buttons still work. Closed shops, kids policies and an empty selection show no button.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { createShop } from '@homie-rocks/studio/shop';
|
|
211
|
+
const shop = createShop();
|
|
212
|
+
shopButton.onclick = () => shop.open();
|
|
213
|
+
itemButton.onclick = () => shop.open('supporter');
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
These calls open the same shop in games and apps. Inside a player card, purchases leave for the studio's shop; ordinary play/open pages start Stripe checkout. The studio's TV and kids policies still apply.
|
package/site/SITE.md
CHANGED
|
@@ -398,7 +398,7 @@ A studio with a `shop.json` ([../shop/SHOP.md](../shop/SHOP.md)) has `/shop/` (i
|
|
|
398
398
|
one neutral age question, Buy on Stripe's own page, or a link for a parent), `/shop/thanks`, `/shop/refunds/` (the
|
|
399
399
|
refund policy every seller publishes) and `/shop/parent/<link>` (what a parent opens). The account page lists the
|
|
400
400
|
player's purchases and badges, with the self-serve refund of an unused item. A play page whose game the studio
|
|
401
|
-
sells something for carries the store sheet
|
|
401
|
+
sells something for carries the store sheet, and play/open screens show a visible Shop button in a reserved strip above the content. `screen.shop: false` in game.json or app.json hides this button; `createShop().open()` or `.open(item)` opens the same sheet from the experience's own button. Closed shops and experiences without items show no button; the protective shop policy hides it on kids servers and sends big-screen shoppers to a phone. The studio can edit these policies in shop.json. With a `shop.json`, `/shop/` belongs to the
|
|
402
402
|
shop (a `site/pages/shop/` is not served); without one it is the studio's own. The directory manifest says only
|
|
403
403
|
whether the shop is open and its till.
|
|
404
404
|
|
package/worker/account-page.mjs
CHANGED
|
@@ -108,11 +108,12 @@ export function accountPage(cat, { origin = '', next = '/account/', owner = null
|
|
|
108
108
|
const studio = cat?.studio?.name ?? 'Studio';
|
|
109
109
|
const boot = { next, owner, link, mode, features, studio };
|
|
110
110
|
const games = (cat?.games ?? []).filter((g) => g.saves).map((g) => g.name);
|
|
111
|
-
const
|
|
111
|
+
const app = (cat.games ?? []).some((g) => g.kind === 'app' && next.startsWith(`/${g.id}/`));
|
|
112
|
+
const back = next !== '/account/' ? `<p class="back"><a class="ghost" href="${esc(next)}" data-back>Back to the ${app ? 'app' : 'game'}</a></p>` : '';
|
|
112
113
|
const main = `<section class="acct" data-account>
|
|
113
114
|
<p class="kicker">${esc(studio)}</p>
|
|
114
|
-
<h1
|
|
115
|
-
<p class="lead"
|
|
115
|
+
<h1>${app ? 'Your account' : 'Your player account'}</h1>
|
|
116
|
+
<p class="lead">${app ? 'Sign in for your role in this app.' : 'Keep your characters and progress on every device you play on.'} It is an account on ${esc(studio)} only, made with a passkey: Face ID, a fingerprint or your device's PIN. No password, and no email needed.</p>
|
|
116
117
|
<noscript><p class="lead">This page needs JavaScript for passkeys.</p></noscript>
|
|
117
118
|
<div class="card" data-view="loading"><p>Looking for your account…</p></div>
|
|
118
119
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** App metadata shared by the build and the Worker. No browser role grants authority. */
|
|
2
|
+
export const APP_SURFACES = ['phone', 'wall', 'kiosk', 'tablet'];
|
|
3
|
+
export const APP_WORDS = Object.freeze({ open: 'Open', join: 'Scan to join', one: 'person', many: 'people' });
|
|
4
|
+
const key = /^[a-z][a-z0-9-]{0,39}$/;
|
|
5
|
+
const object = (x) => x && typeof x === 'object' && !Array.isArray(x);
|
|
6
|
+
export function appProblems(meta) {
|
|
7
|
+
const bad = [];
|
|
8
|
+
if (!object(meta)) return ['app.json must be an object'];
|
|
9
|
+
const roles = object(meta.roles) ? meta.roles : {};
|
|
10
|
+
const collections = object(meta.records?.collections) ? meta.records.collections : {};
|
|
11
|
+
if (!object(meta.roles) || !Object.keys(meta.roles).length) bad.push('roles must declare at least one role');
|
|
12
|
+
for (const [id, role] of Object.entries(roles)) {
|
|
13
|
+
if (!key.test(id) || !object(role) || typeof role.signIn !== 'boolean' || !Array.isArray(role.can) || role.can.some((s) => !/^(read|create|update|delete):[a-z][a-z0-9-]{0,39}$/.test(s))) bad.push(`role ${id}: declare signIn and can (read/create/update/delete:collection)`);
|
|
14
|
+
}
|
|
15
|
+
if (!Object.values(roles).some((r) => r?.signIn === false)) bad.push('declare a public role');
|
|
16
|
+
if (!object(meta.surfaces) || !Object.keys(meta.surfaces).length) bad.push('declare surfaces');
|
|
17
|
+
for (const [surface, role] of Object.entries(meta.surfaces ?? {})) if (!APP_SURFACES.includes(surface) || !Object.hasOwn(meta.roles ?? {}, role)) bad.push(`surface ${surface}: name a declared role`);
|
|
18
|
+
if (!object(meta.records) || typeof meta.records.persist !== 'boolean' || !object(meta.records.collections)) bad.push('records declares persist and collections');
|
|
19
|
+
for (const [id, c] of Object.entries(collections)) {
|
|
20
|
+
if (!key.test(id) || !object(c) || !object(c.fields)) { bad.push(`collection ${id}: declare fields`); continue; }
|
|
21
|
+
for (const [field, spec] of Object.entries(c.fields)) if (!key.test(field) || !object(spec) || !['string', 'number', 'boolean'].includes(spec.type) || (spec.enum && (!Array.isArray(spec.enum) || !spec.enum.length))) bad.push(`collection ${id}.${field}: invalid field`);
|
|
22
|
+
}
|
|
23
|
+
for (const r of Object.values(roles)) for (const cap of Array.isArray(r?.can) ? r.can : []) if (!Object.hasOwn(meta.records?.collections ?? {}, String(cap).split(':')[1])) bad.push(`unknown collection in ${cap}`);
|
|
24
|
+
for (const [k, v] of Object.entries(meta.words ?? {})) if (!Object.hasOwn(APP_WORDS, k) || typeof v !== 'string' || !v.trim() || v.length > 60) bad.push(`words.${k}: use a short label`);
|
|
25
|
+
return bad;
|
|
26
|
+
}
|
|
27
|
+
export const appWords = (g) => g?.kind === 'app' ? { ...APP_WORDS, ...g.words } : { open: 'Play', join: 'Scan to play', one: 'player', many: 'players' };
|
|
28
|
+
export const openPath = (g) => `/${g.id}/${g.kind === 'app' ? 'open' : 'play'}`;
|
|
29
|
+
export function appRole(meta, url) {
|
|
30
|
+
const surface = url.searchParams.get('surface') ?? ((url.pathname.endsWith('/tv') || url.pathname.endsWith('/watch')) || url.searchParams.get('want') === 'screen' ? 'wall' : 'phone');
|
|
31
|
+
return url.searchParams.get('role') ?? meta.surfaces?.[surface] ?? Object.keys(meta.roles ?? {}).find((r) => !meta.roles[r].signIn);
|
|
32
|
+
}
|
|
33
|
+
export function recordProblem(collection, data, { create = false } = {}) {
|
|
34
|
+
if (!object(data) || JSON.stringify(data).length > 8192) return 'record must be an object below 8 KB';
|
|
35
|
+
for (const k of Object.keys(data)) if (!Object.hasOwn(collection.fields, k)) return `unknown field ${k}`;
|
|
36
|
+
for (const [k, s] of Object.entries(collection.fields)) {
|
|
37
|
+
const v = data[k];
|
|
38
|
+
if (v === undefined) { if (s.required) return `missing ${k}`; continue; }
|
|
39
|
+
if (typeof v !== s.type || (s.type === 'number' && !Number.isFinite(v)) || (typeof v === 'string' && v.length > (s.maxLength ?? 256)) || (s.enum && !s.enum.includes(v)) || (create && s.initial !== undefined && v !== s.initial)) return `invalid ${k}`;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Lasting app records in the studio's own D1. Room hosts never authorize writes. */
|
|
2
|
+
import { appRole, recordProblem } from './app-format.mjs';
|
|
3
|
+
import { isOwner, sameOrigin } from './office.mjs';
|
|
4
|
+
import { players } from './players.mjs';
|
|
5
|
+
import { ownerAllowed } from './stats.mjs';
|
|
6
|
+
import { appOriginOf } from './standalone.mjs';
|
|
7
|
+
export const APPS_MIGRATION_FILE = '0014_studio_apps.sql';
|
|
8
|
+
export const APPS_MIGRATION = `CREATE TABLE IF NOT EXISTS app_records (
|
|
9
|
+
app TEXT NOT NULL, collection TEXT NOT NULL, id TEXT NOT NULL, value TEXT NOT NULL,
|
|
10
|
+
version INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL,
|
|
11
|
+
PRIMARY KEY (app, collection, id)
|
|
12
|
+
);
|
|
13
|
+
CREATE TABLE IF NOT EXISTS app_roles (
|
|
14
|
+
app TEXT NOT NULL, role TEXT NOT NULL, player TEXT NOT NULL,
|
|
15
|
+
PRIMARY KEY (app, role, player)
|
|
16
|
+
);
|
|
17
|
+
CREATE TABLE IF NOT EXISTS app_role_links (
|
|
18
|
+
app TEXT NOT NULL, role TEXT NOT NULL, token TEXT NOT NULL,
|
|
19
|
+
PRIMARY KEY (app, role)
|
|
20
|
+
);
|
|
21
|
+
`;
|
|
22
|
+
const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } });
|
|
23
|
+
const fail = (status, error) => json({ ok: false, error }, status);
|
|
24
|
+
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
25
|
+
async function bodyOf(request) {
|
|
26
|
+
const reader = request.body?.getReader();
|
|
27
|
+
if (!reader) return {};
|
|
28
|
+
let size = 0; const chunks = [];
|
|
29
|
+
for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > 12_288) { await reader.cancel(); throw new Error('body too large'); } chunks.push(value); }
|
|
30
|
+
const bytes = new Uint8Array(size); let at = 0;
|
|
31
|
+
for (const chunk of chunks) { bytes.set(chunk, at); at += chunk.length; }
|
|
32
|
+
return JSON.parse(new TextDecoder().decode(bytes) || '{}');
|
|
33
|
+
}
|
|
34
|
+
/** Private links conceal staff addresses; a signed-in grant is still required on every request. */
|
|
35
|
+
export async function appAccess(request, env, meta, url) {
|
|
36
|
+
const role = appRole(meta, url);
|
|
37
|
+
const declared = Object.hasOwn(meta.roles ?? {}, role) ? meta.roles[role] : null;
|
|
38
|
+
if (!declared) return { ok: false, status: 404, error: 'unknown role' };
|
|
39
|
+
if (!declared.signIn) return { ok: true, role, can: declared.can };
|
|
40
|
+
if (!env.DB) return { ok: false, status: 503, error: 'app roles need the studio database' };
|
|
41
|
+
const link = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, role).first();
|
|
42
|
+
if (!link || link.token !== url.searchParams.get('access')) return { ok: false, status: 404, error: 'private role link required' };
|
|
43
|
+
if (await isOwner(request, env)) return { ok: true, role, can: declared.can };
|
|
44
|
+
const person = await players.of(request, env);
|
|
45
|
+
if (!person || person.guest) return { ok: false, status: 401, error: 'sign in to this studio' };
|
|
46
|
+
const grant = await env.DB.prepare('SELECT player FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3').bind(meta.id, role, person.id).first();
|
|
47
|
+
return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
|
|
48
|
+
}
|
|
49
|
+
export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
50
|
+
if (!sub.startsWith('api/app/')) return null;
|
|
51
|
+
if (!env.DB) return fail(503, 'app records need the studio database');
|
|
52
|
+
try {
|
|
53
|
+
if (sub === 'api/app/roles') {
|
|
54
|
+
const key = await ownerAllowed(request, env, { kinds: ['office'] });
|
|
55
|
+
if (!key && !(await isOwner(request, env))) return fail(403, 'owner only');
|
|
56
|
+
if (request.method !== 'POST') return fail(405, 'POST required');
|
|
57
|
+
if (!key && !sameOrigin(request, url)) return fail(403, 'same origin required');
|
|
58
|
+
const b = await bodyOf(request);
|
|
59
|
+
if (!Object.hasOwn(meta.roles, b.role) || !meta.roles[b.role].signIn) return fail(400, 'name a sign-in role');
|
|
60
|
+
if (b.player !== undefined) {
|
|
61
|
+
if (!ID.test(b.player)) return fail(400, 'invalid account');
|
|
62
|
+
const p = await players.get(b.player, env);
|
|
63
|
+
if (!p || p.guest) return fail(400, 'role grants require an existing signed-in account');
|
|
64
|
+
await env.DB.prepare(b.revoke === true ? 'DELETE FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3' : 'INSERT OR IGNORE INTO app_roles (app, role, player) VALUES (?1, ?2, ?3)').bind(meta.id, b.role, b.player).run();
|
|
65
|
+
}
|
|
66
|
+
const token = crypto.randomUUID().replaceAll('-', '');
|
|
67
|
+
if (b.rotate === true) await env.DB.prepare('DELETE FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).run();
|
|
68
|
+
await env.DB.prepare('INSERT OR IGNORE INTO app_role_links (app, role, token) VALUES (?1, ?2, ?3)').bind(meta.id, b.role, token).run();
|
|
69
|
+
const row = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).first();
|
|
70
|
+
return json({ ok: true, link: `${url.origin}/${meta.id}/open?role=${b.role}&access=${row.token}`, role: b.role });
|
|
71
|
+
}
|
|
72
|
+
const match = /^api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(sub);
|
|
73
|
+
if (!match || !Object.hasOwn(meta.records?.collections ?? {}, match[1])) return fail(404, 'unknown collection');
|
|
74
|
+
if (meta.records.persist !== true) return fail(409, 'use netplay state for transient records, or enable records.persist');
|
|
75
|
+
const access = await appAccess(request, env, meta, url);
|
|
76
|
+
if (!access.ok) return fail(access.status, access.error);
|
|
77
|
+
const [, collection, id] = match;
|
|
78
|
+
const op = ({ GET: 'read', POST: 'create', PUT: 'update', DELETE: 'delete' })[request.method];
|
|
79
|
+
if (!op || !access.can.includes(`${op}:${collection}`)) return fail(403, 'role cannot perform this operation');
|
|
80
|
+
if (op !== 'read' && !sameOrigin(request, url) && !(appOriginOf(request) && !meta.roles[access.role].signIn)) return fail(403, 'same origin required');
|
|
81
|
+
if (op === 'read') {
|
|
82
|
+
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(meta.id, collection, id).first()].filter(Boolean)
|
|
83
|
+
: (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(meta.id, collection).all()).results;
|
|
84
|
+
return json({ ok: true, records: rows.map((r) => ({ id: r.id, data: JSON.parse(r.value), version: r.version, updatedAt: r.updated_at })) });
|
|
85
|
+
}
|
|
86
|
+
if (!id) return fail(400, 'record id required');
|
|
87
|
+
const body = await bodyOf(request);
|
|
88
|
+
if (op !== 'delete') { const bad = recordProblem(meta.records.collections[collection], body.data, { create: op === 'create' }); if (bad) return fail(400, bad); }
|
|
89
|
+
if (op !== 'create' && (!Number.isSafeInteger(body.version) || body.version < 1)) return fail(400, 'record version required');
|
|
90
|
+
const stmt = op === 'create'
|
|
91
|
+
? 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(meta.id, collection, id, JSON.stringify(body.data), Date.now())
|
|
92
|
+
: 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(meta.id, collection, id, JSON.stringify(body.data), Date.now(), body.version)
|
|
93
|
+
: env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(meta.id, collection, id, body.version);
|
|
94
|
+
const result = await stmt.run();
|
|
95
|
+
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');
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof SyntaxError || error.message === 'body too large') return fail(400, error.message);
|
|
98
|
+
return fail(503, 'app database unavailable; apply studio migrations');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Only the shell's own frame can ask; role and access come from the shell, never the message. */
|
|
2
|
+
export const APP_SHELL_JS = String.raw`(function () {
|
|
3
|
+
var boot = window.__HOMIE_PLAY || window.__HOMIE_APP || window.__HOMIE_WATCH;
|
|
4
|
+
if (!boot || boot.kind !== 'app') return;
|
|
5
|
+
var frame = document.querySelector('iframe.game') || document.querySelector('iframe');
|
|
6
|
+
var params = new URLSearchParams(location.search);
|
|
7
|
+
var inflight = 0;
|
|
8
|
+
addEventListener('message', async function (e) {
|
|
9
|
+
var m = e.data;
|
|
10
|
+
if (frame && e.source === frame.contentWindow && m?.type === 'homie-app-ticket' && /^[a-f0-9]{32}$/.test(m.ticket)) { params.set('ticket', m.ticket); history.replaceState(null, '', location.pathname + '?' + params); return; }
|
|
11
|
+
if (!frame || e.source !== frame.contentWindow || !m || m.type !== 'homie-app' || !/^[a-f0-9]{32}$/.test(m.id || '')) return;
|
|
12
|
+
if (!/^[a-z][a-z0-9-]{0,39}$/.test(m.collection || '') || !['GET','POST','PUT','DELETE'].includes(m.method) || (m.record !== undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(m.record))) return;
|
|
13
|
+
var result;
|
|
14
|
+
if (inflight >= 8) result = { ok: false, error: 'too many requests' };
|
|
15
|
+
else {
|
|
16
|
+
inflight++;
|
|
17
|
+
try {
|
|
18
|
+
var q = new URLSearchParams();
|
|
19
|
+
['role','surface','access'].forEach(function (k) { var v = params.get(k); if (v) q.set(k, v); });
|
|
20
|
+
if (boot.screen && !q.has('role')) q.set('surface', 'wall');
|
|
21
|
+
var path = (boot.site || '') + '/' + boot.game + '/api/app/records/' + m.collection + (m.record ? '/' + m.record : '') + '?' + q;
|
|
22
|
+
var res = await fetch(path, { method: m.method, headers: { 'content-type': 'application/json' }, body: m.method === 'GET' ? undefined : JSON.stringify({ data: m.data, version: m.version }) });
|
|
23
|
+
result = await res.json();
|
|
24
|
+
} catch (err) { result = { ok: false, error: 'records unavailable; reconnect and retry' }; }
|
|
25
|
+
finally { inflight--; }
|
|
26
|
+
}
|
|
27
|
+
e.source.postMessage({ type: 'homie-app-result', id: m.id, result: result }, '*');
|
|
28
|
+
});
|
|
29
|
+
})();`;
|
package/worker/embed.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appWords, openPath } from './app-format.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* Player cards. X's reference: developer.x.com, "Cards: player card" (archived)
|
|
3
4
|
* (redirects to docs.x.com/overview as checked 2026-10-08). Surviving official sample:
|
|
@@ -71,7 +72,8 @@ export function httpsAddress(value, origin) {
|
|
|
71
72
|
try { const u = new URL(value, origin); return u.protocol === 'https:' && !u.username && !u.password ? u.href : null; } catch { return null; }
|
|
72
73
|
}
|
|
73
74
|
export function playerProperties(cat, game, { origin = '', title = game?.name, description = game?.blurb } = {}) {
|
|
74
|
-
|
|
75
|
+
if (!game) return {};
|
|
76
|
+
const player = httpsAddress(`${openPath(game)}/embed`, origin);
|
|
75
77
|
const measured = game?.playerImage;
|
|
76
78
|
const picture = measured && httpsAddress(measured.src, origin);
|
|
77
79
|
// twitter:site goes out when the studio has set its handle; a card is never withheld for the lack of one
|
|
@@ -80,7 +82,7 @@ export function playerProperties(cat, game, { origin = '', title = game?.name, d
|
|
|
80
82
|
if (!playerEnabled(cat, game) || !player || !picture || playerImageRule(measured)) return {};
|
|
81
83
|
return { ...(handle ? { 'twitter:site': handle } : {}), 'twitter:card': 'player', 'twitter:player': player, 'twitter:player:width': PLAYER_SIZE.width,
|
|
82
84
|
'twitter:player:height': PLAYER_SIZE.height, 'twitter:image': picture, 'twitter:title': title,
|
|
83
|
-
'twitter:description': String(description ||
|
|
85
|
+
'twitter:description': String(description || `${appWords(game).open} ${game.name} in your browser.`).slice(0, 200) };
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
/** Browser restrictions belong to the outer host; failures must not kill a game's input loop.
|
package/worker/index.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { appAccess, appRecordsRoute } from './app-records.mjs';
|
|
2
|
+
import { appRole, openPath } from './app-format.mjs';
|
|
1
3
|
/**
|
|
2
4
|
* @homie-rocks/studio/worker — a studio's own site, on the studio's own Cloudflare.
|
|
3
5
|
*
|
|
@@ -173,6 +175,10 @@ async function sellingShop(env, cat) {
|
|
|
173
175
|
try { return (await readiness(env, shop)).ready ? shop : null; } catch { return null; }
|
|
174
176
|
}
|
|
175
177
|
|
|
178
|
+
async function availableShellShop(env, cat, game, options) {
|
|
179
|
+
return await sellingShop(env, cat) ? shellShop(cat, game, options) : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
176
182
|
async function postsOf(env, origin) {
|
|
177
183
|
try {
|
|
178
184
|
const res = await env.ASSETS.fetch(new Request(`${origin}/_site/posts.json`));
|
|
@@ -485,6 +491,7 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
485
491
|
const ver = versionOf(meta?.netplay?.version);
|
|
486
492
|
// The play page's switches for the game (section 24): the allowed names only, checked again here.
|
|
487
493
|
const params = paramsFrom(url.searchParams, meta);
|
|
494
|
+
if (meta.kind === 'app' && !params.role) params.role = appRole(meta, url);
|
|
488
495
|
const cfg = {
|
|
489
496
|
v: 1,
|
|
490
497
|
url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}${ver ? `&gv=${encodeURIComponent(ver)}` : ''}`,
|
|
@@ -511,7 +518,7 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
511
518
|
...(meta?.saves && want === 'play' ? { saves: true } : {}),
|
|
512
519
|
// The studio sells something in this game (shop/SHOP.md): the play shell answers @homie-rocks/studio/shop. A
|
|
513
520
|
// watcher has no shop; the shell follows the studio shop policy for kids servers and television checkout.
|
|
514
|
-
...(!agent && !watching &&
|
|
521
|
+
...(!agent && !watching && await availableShellShop(env, cat, game) ? { shop: true } : {}),
|
|
515
522
|
};
|
|
516
523
|
let html = await res.text();
|
|
517
524
|
const head = `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
@@ -601,6 +608,8 @@ export default {
|
|
|
601
608
|
const url = new URL(request.url);
|
|
602
609
|
// A standalone copy's Lobby call (worker/standalone.mjs): every answer to it can be read by the app's page, an
|
|
603
610
|
// unknown game's 404 and a failure included, so the app can say why it plays offline. No other address is opened.
|
|
611
|
+
const appRecords = isAppOrigin(request) && /^\/[^/]+\/api\/app\/records\//.test(url.pathname);
|
|
612
|
+
if (appRecords && request.method === 'OPTIONS') return appCors(request, new Response(null, { status: 204, headers: { 'access-control-allow-methods': 'GET, POST, PUT, DELETE', 'access-control-allow-headers': 'content-type' } }));
|
|
604
613
|
const appLobby = isAppOrigin(request) && /^\/[^/]+\/api\/lobby\/?$/.test(url.pathname);
|
|
605
614
|
let res;
|
|
606
615
|
try { res = finish(await route(request, env, ctx), url.pathname); } catch (error) {
|
|
@@ -608,7 +617,7 @@ export default {
|
|
|
608
617
|
try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-route-failed', path: url.pathname, error: errorLine(error) })); } catch { /* no console */ }
|
|
609
618
|
res = json({ ok: false, error: 'failed' }, 500);
|
|
610
619
|
}
|
|
611
|
-
if (appLobby) return appCors(request, res);
|
|
620
|
+
if (appLobby || appRecords) return appCors(request, res);
|
|
612
621
|
// A referral's arrival (worker/referrals.mjs): only a person's page load with ?via= of another site, on a studio
|
|
613
622
|
// that pays referrals. The page is answered first; a cookie is added to it only then.
|
|
614
623
|
if (url.searchParams.has('via') && res && res.status === 200 && /text\/html/i.test(res.headers.get('content-type') ?? '')) {
|
|
@@ -691,7 +700,7 @@ async function route(request, env, ctx) {
|
|
|
691
700
|
// its mark is here). A Preview says it is one.
|
|
692
701
|
...(cat.studio?.build ? { build: { commit: cat.studio.build.commit ?? null, branch: cat.studio.build.branch ?? null, at: cat.studio.build.at ?? null, changes: (cat.studio.build.changes ?? []).slice(0, 50), ...(env.HOMIE_PREVIEW === '1' ? { preview: true } : {}) } } : {}),
|
|
693
702
|
// Only the public games (a private or invite-only game leaves the directory the next time it reads this).
|
|
694
|
-
games: await Promise.all((cat.games ?? []).map(async (g) => {
|
|
703
|
+
games: await Promise.all((cat.games ?? []).filter((g) => g.kind !== 'app').map(async (g) => {
|
|
695
704
|
// The card picture is the landing's hero still (what the landing leads with), else the game's cover.
|
|
696
705
|
const cover = gameCover(g);
|
|
697
706
|
// The licence the game names for itself (an SPDX id), when it names one (worker/license.mjs). Nothing here
|
|
@@ -709,6 +718,7 @@ async function route(request, env, ctx) {
|
|
|
709
718
|
...(byGame ? { played: byGame[g.id] ?? { days: 7, plays: 0, rounds: 0 } } : {}),
|
|
710
719
|
};
|
|
711
720
|
})),
|
|
721
|
+
...((cat.games ?? []).some((g) => g.kind === 'app') ? { apps: cat.games.filter((g) => g.kind === 'app').map((g) => ({ id: g.id, kind: 'app', name: g.name, blurb: g.blurb, page: `${url.origin}/${g.id}/`, open: `${url.origin}/${g.id}/open`, wall: `${url.origin}/${g.id}/tv`, surfaces: Object.keys(g.surfaces ?? {}), ...(g.built ? { build: g.built } : {}) })) } : {}),
|
|
712
722
|
songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music', cat)),
|
|
713
723
|
videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos', cat)),
|
|
714
724
|
// The studio's posts, for the hub (the full text is in /posts/feed.json).
|
|
@@ -829,7 +839,7 @@ async function route(request, env, ctx) {
|
|
|
829
839
|
const { rooms, live } = await roomsOf(env, cat.games ?? []);
|
|
830
840
|
return homePage(cat, { origin: url.origin, rooms, live });
|
|
831
841
|
}
|
|
832
|
-
if (['games', 'rooms', 'posts'].includes(parts[0]) && parts.length === 1) {
|
|
842
|
+
if (['games', 'apps', 'rooms', 'posts'].includes(parts[0]) && parts.length === 1) {
|
|
833
843
|
const cat = await getCat();
|
|
834
844
|
const has = sectionsOf(cat).some((s) => s.key === parts[0]);
|
|
835
845
|
if (!has) return notFoundPage(parts[0] === 'posts' ? 'This studio has no posts yet.' : 'This studio has no games yet.', cat);
|
|
@@ -837,7 +847,7 @@ async function route(request, env, ctx) {
|
|
|
837
847
|
await countVisit(request, env, ctx, parts[0]);
|
|
838
848
|
if (parts[0] === 'posts') return postsPage(cat, { origin: url.origin });
|
|
839
849
|
const { rooms, live } = await roomsOf(env, cat.games ?? []);
|
|
840
|
-
return parts[0]
|
|
850
|
+
return ['games', 'apps'].includes(parts[0]) ? gamesPage(cat, { origin: url.origin, live, kind: parts[0] === 'apps' ? 'app' : 'game' }) : roomsPage(cat, { origin: url.origin, rooms });
|
|
841
851
|
}
|
|
842
852
|
if (parts[0] === 'posts' && parts.length === 2 && POST_SLUG.test(parts[1])) {
|
|
843
853
|
const cat = await getCat();
|
|
@@ -857,6 +867,7 @@ async function route(request, env, ctx) {
|
|
|
857
867
|
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
|
|
858
868
|
const cat = await getCat();
|
|
859
869
|
let sub = parts.slice(1).join('/');
|
|
870
|
+
if (meta.kind === 'app' && /^open(?:\/(embed|preview))?$/.test(sub)) sub = sub.replace(/^open/, 'play');
|
|
860
871
|
if (sub === '__restart') {
|
|
861
872
|
if (env.HOMIE_PREVIEW !== '1' || request.method !== 'POST' || !hostedGame(game)) return new Response('not found', { status: 404 });
|
|
862
873
|
const room = url.searchParams.get('room') ?? '';
|
|
@@ -869,6 +880,19 @@ async function route(request, env, ctx) {
|
|
|
869
880
|
// with access, and the owner may have made its rooms smaller than the game's own seats.
|
|
870
881
|
const launch = launchOf(meta, settings, env);
|
|
871
882
|
const max = seatsFor(meta, settings);
|
|
883
|
+
if (meta.kind === 'app') {
|
|
884
|
+
if (launch !== 'public' && !(await accessOf(request, env, game, launch)).ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
885
|
+
const api = await appRecordsRoute(request, env, meta, url, sub);
|
|
886
|
+
if (api) return api;
|
|
887
|
+
if (['play', 'tv', 'watch'].includes(sub)) {
|
|
888
|
+
let a;
|
|
889
|
+
try { a = await appAccess(request, env, meta, url); } catch { return json({ ok: false, error: 'app roles unavailable; apply studio migrations' }, 503); }
|
|
890
|
+
if (!a.ok) {
|
|
891
|
+
if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
|
|
892
|
+
return json({ ok: false, error: a.error }, a.status);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
872
896
|
const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
|
|
873
897
|
if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
|
|
874
898
|
|
|
@@ -963,7 +987,7 @@ async function route(request, env, ctx) {
|
|
|
963
987
|
if (sub === 'play/preview' && env.HOMIE_EMBED_PREVIEW === '1' && isLocalOrigin(url.origin)) return embedPreview(cat, meta, url.origin);
|
|
964
988
|
if (sub === 'tv' || sub === 'play' || sub === 'play/embed') {
|
|
965
989
|
const embed = sub === 'play/embed';
|
|
966
|
-
if (embed && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage('This game does not play inside a post. Open its studio page to play.', cat);
|
|
990
|
+
if (embed && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage(meta.kind === 'app' ? 'This app does not open inside a post. Open its studio page.' : 'This game does not play inside a post. Open its studio page to play.', cat);
|
|
967
991
|
const screen = !embed && (sub === 'tv' || url.searchParams.get('screen') === '1');
|
|
968
992
|
const asked = embed ? null : askedRoom;
|
|
969
993
|
if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen });
|
|
@@ -996,20 +1020,20 @@ async function route(request, env, ctx) {
|
|
|
996
1020
|
if (!room) {
|
|
997
1021
|
try { room = (await (await lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${versionOf(meta?.netplay?.version) ? `&ver=${encodeURIComponent(versionOf(meta.netplay.version))}` : ''}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
|
|
998
1022
|
}
|
|
999
|
-
const joinUrl = `${url.origin}
|
|
1023
|
+
const joinUrl = `${url.origin}${openPath(meta)}${room ? `?room=${encodeURIComponent(room)}` : ''}`;
|
|
1000
1024
|
// Local development: no phone can open this computer's own address, so no code for it; the card says to deploy.
|
|
1001
1025
|
const local = isLocalOrigin(url.origin);
|
|
1002
1026
|
let qr = null;
|
|
1003
1027
|
if (!local) try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
|
|
1004
1028
|
// Television checkout and kids-server visibility follow the studio shop policy.
|
|
1005
|
-
const sh =
|
|
1029
|
+
const sh = await availableShellShop(env, cat, game, { kids: pol.kids });
|
|
1006
1030
|
let shopQr = null;
|
|
1007
1031
|
if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = null; }
|
|
1008
1032
|
return playPage(cat, meta, { origin: url.origin, screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null });
|
|
1009
1033
|
}
|
|
1010
1034
|
await countVisit(request, env, ctx, game, 'play');
|
|
1011
1035
|
shareDaily(cat, url, ctx);
|
|
1012
|
-
return playPage(cat, meta, { origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop:
|
|
1036
|
+
return playPage(cat, meta, { origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: await availableShellShop(env, cat, game, { kids: pol.kids }) });
|
|
1013
1037
|
}
|
|
1014
1038
|
if (sub === 'watch') {
|
|
1015
1039
|
// The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
|