@homie-rocks/studio 0.58.1 → 0.58.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 +33 -0
- package/apps/APPS.md +1 -1
- package/bin/homie-studio.mjs +14 -4
- package/guides/app/SKILL.md +1 -1
- package/lib/template-history.json +84 -0
- package/package.json +1 -1
- package/worker/app-records.mjs +58 -6
- package/worker/app-streams.mjs +3 -3
- package/worker/build-id.mjs +1 -1
- package/worker/lobby-peak.mjs +2 -1
- package/worker/lobby-shards.mjs +28 -0
- package/worker/lobby-summary.mjs +3 -1
- package/worker/lobby.mjs +19 -16
- package/worker/mcp-event-streams.mjs +4 -3
- package/worker/mcp-events.mjs +1 -1
- package/worker/route-auth.mjs +1 -1
- package/worker/runtime-support.mjs +5 -1
- package/worker/stats.mjs +11 -1
- package/worker/table.mjs +2 -2
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,39 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.58.3] - 2026-10-18
|
|
21
|
+
|
|
22
|
+
**Plugin 0.58.2**
|
|
23
|
+
|
|
24
|
+
An idle studio no longer keeps its lobby awake. Reading who is playing uses the summary already stored for the lounge, and a room that is merely sitting there lets its lobby shard hibernate.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Who is playing, the office and the room list read the lobby summary instead of waking all 16 lobby shards.
|
|
29
|
+
- A lobby shard publishes that summary in the report itself. It no longer sets a 10 second alarm, which had kept the shard awake for as long as anyone was seated.
|
|
30
|
+
- A room with nobody new arriving reports again after two and a half minutes, which is long enough for the shard to sleep and short enough that "playing now" still includes it.
|
|
31
|
+
- An open event stream or app stream no longer heartbeats every 15 seconds. That heartbeat was shorter than eviction, so the lobby stayed awake for as long as the page was open.
|
|
32
|
+
- The live count on a game page reads today's peak from the stats table. It no longer wakes the peak object every 15 seconds.
|
|
33
|
+
|
|
34
|
+
### Upgrade notes
|
|
35
|
+
|
|
36
|
+
- Upgrade the studio to pick this up. Until then, an open office or stats page still wakes every lobby shard.
|
|
37
|
+
|
|
38
|
+
## [0.58.2] - 2026-10-17
|
|
39
|
+
|
|
40
|
+
**Plugin 0.58.2**
|
|
41
|
+
|
|
42
|
+
The studio owner opens a private app role. A staff link can expire, and it can be listed or revoked on its own.
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- A signed-in studio owner opens a private app role with the owner session. Everyone else still needs that role's link.
|
|
47
|
+
- `homie-studio app role <id> --list` lists each private role's link and expiry. `--expires` takes a day count (`30d`) or a unix time in milliseconds. `--revoke-link` revokes the link and leaves a player's own grant as it was.
|
|
48
|
+
|
|
49
|
+
### Upgrade notes
|
|
50
|
+
|
|
51
|
+
- Upgrade the studio to pick this up. A link issued before this version has no expiry until one is set.
|
|
52
|
+
|
|
20
53
|
## [0.58.1] - 2026-10-17
|
|
21
54
|
|
|
22
55
|
**Plugin 0.58.1**
|
package/apps/APPS.md
CHANGED
|
@@ -26,7 +26,7 @@ Use existing netplay events for refresh notices and shared animation. A missed o
|
|
|
26
26
|
|
|
27
27
|
## Staff
|
|
28
28
|
|
|
29
|
-
`homie-studio app role welcome staff --url <origin>` returns an unpredictable role address for the owner. Add `--player <account-id>` to grant an existing non-guest account, or `--revoke` with that player to remove it. The command reuses the office's short-lived owner key. Accounts/passkeys and the office owner's existing session are reused. An anonymous browser with the link goes to `/account/` to sign in; a signed-in account without a grant receives 403. Every record call rechecks the grant, so revocation applies to open tablets too.
|
|
29
|
+
`homie-studio app role welcome staff --url <origin>` returns an unpredictable role address for the owner. Add `--player <account-id>` to grant an existing non-guest account, or `--revoke` with that player to remove it. `--list` lists each sign-in role's link and expiry. `--revoke-link` deletes that role's link, so the old address answers 404. `--expires <ms>` or `--expires 7d` sets an expiry within 366 days; omit it for a link that does not expire. The command reuses the office's short-lived owner key. A signed-in studio owner opens their own private roles without a link. Accounts/passkeys and the office owner's existing session are reused. An anonymous browser with the link goes to `/account/` to sign in; a signed-in account without a grant receives 403. An expired or revoked link answers 404. Every record call rechecks the grant, so revocation applies to open tablets too.
|
|
30
30
|
|
|
31
31
|
The private address hides the staff entrance; it is not a bearer permission. It is never in public metadata. Passkeys require HTTPS or localhost. `dev --lan` exposes the local site to the same Wi-Fi, but does not weaken staff authentication. On local HTTP, provision test sessions explicitly in tests or use a secure local origin for real sign-in.
|
|
32
32
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* extension, Claude Code or any MCP client builds in the same chat that shows
|
|
15
15
|
* the cards (lib/mcp.mjs); --studios: the folder the studios live in
|
|
16
16
|
* homie-studio app new <id> [--from welcome|gathering|giving|quiz|jobs] [--name "<App Name>"]
|
|
17
|
-
* homie-studio app role <id> <role> [--player <account>] [--revoke] [--url <site>]
|
|
17
|
+
* homie-studio app role <id> [--list] | <id> <role> [--player <account>] [--revoke] [--revoke-link] [--expires <ms|Nd>] [--url <site>]
|
|
18
18
|
* homie-studio game new <id> [--from gem-rush] [--name "<Game Name>"]
|
|
19
19
|
* homie-studio games
|
|
20
20
|
* homie-studio build [<id>] [--maps] [--types] [--long-check] [--quick] (--maps: also keep each bundle's source map and
|
|
@@ -382,7 +382,7 @@ import { standaloneDeployNotes } from '../lib/standalone.mjs';
|
|
|
382
382
|
const argv = process.argv.slice(2);
|
|
383
383
|
const flags = new Map();
|
|
384
384
|
// build --quick keeps compilation and type checks, skips rules generated play/smoke checks.
|
|
385
|
-
const BOOL_FLAGS = ['webgl-failure', 'quick', 'public', 'lan', 'off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'giving', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
|
|
385
|
+
const BOOL_FLAGS = ['webgl-failure', 'quick', 'public', 'lan', 'off', 'revoke', 'revoke-link', 'list', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'giving', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
|
|
386
386
|
const positional = [];
|
|
387
387
|
for (let i = 0; i < argv.length; i++) {
|
|
388
388
|
const a = argv[i];
|
|
@@ -950,8 +950,18 @@ async function main() {
|
|
|
950
950
|
if (cmd === 'app' && sub === 'role') {
|
|
951
951
|
const { withKey } = await import('../lib/office.mjs');
|
|
952
952
|
const id = positional[2]; const role = positional[3];
|
|
953
|
-
|
|
954
|
-
|
|
953
|
+
const usage = 'app role <id> --list | app role <id> <role> [--player <account>] [--revoke] [--revoke-link] [--expires <ms|Nd>] [--url <site>]';
|
|
954
|
+
if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(id ?? '')) throw new Error(usage);
|
|
955
|
+
if (flags.has('list')) return withKey(root, flags.get('url'), (call) => call(`/${id}/api/app/roles`, { list: true }));
|
|
956
|
+
if (!/^[a-z][a-z0-9-]{0,39}$/.test(role ?? '')) throw new Error(usage);
|
|
957
|
+
let expires;
|
|
958
|
+
if (flags.has('expires')) {
|
|
959
|
+
const raw = String(flags.get('expires') ?? '');
|
|
960
|
+
const days = /^(\d{1,3})d$/.exec(raw);
|
|
961
|
+
expires = days ? Date.now() + Number(days[1]) * 86400000 : Number(raw);
|
|
962
|
+
if (!Number.isSafeInteger(expires) || (days && (Number(days[1]) < 1 || Number(days[1]) > 366))) throw new Error(usage);
|
|
963
|
+
}
|
|
964
|
+
return withKey(root, flags.get('url'), (call) => call(`/${id}/api/app/roles`, { role, studio:flags.has('studio'), ...(flags.get('player') ? { player: flags.get('player') } : {}), revoke: flags.has('revoke'), ...(flags.has('revoke-link') ? { revokeLink: true } : {}), ...(expires !== undefined ? { expires } : {}) }));
|
|
955
965
|
}
|
|
956
966
|
if (cmd === 'app' && sub === 'new') return newApp(root, positional[2], { name: flags.get('name'), from: flags.get('from') ?? 'welcome' });
|
|
957
967
|
if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
|
package/guides/app/SKILL.md
CHANGED
|
@@ -162,7 +162,7 @@ Use `createAppRecords` from `@homie-rocks/studio/apps` for lasting records. Decl
|
|
|
162
162
|
|
|
163
163
|
Public collections reach everyone allowed to read them. A ticket number or filtering the HUD is not privacy. Keep personal/private records in a different collection whose read permission excludes customers and walls. Do not put contact details in the starter's public queue.
|
|
164
164
|
|
|
165
|
-
`homie-studio app role <id> staff --player <account-id> --url <site>` grants an existing signed-in account and returns its private role address. Without `--player` it returns the owner's role address. The existing owner check protects issuance; the link alone grants nothing. `--revoke` removes a named account's grant. Staff use the studio's existing account/passkey sign-in. Keep these links private.
|
|
165
|
+
`homie-studio app role <id> staff --player <account-id> --url <site>` grants an existing signed-in account and returns its private role address. Without `--player` it returns the owner's role address. The existing owner check protects issuance; the link alone grants nothing. `--revoke` removes a named account's grant. `--list` lists links, `--revoke-link` deletes one, and `--expires 7d` (or a unix millisecond time) sets an expiry within 366 days. A signed-in studio owner opens their own private roles without a link. Staff use the studio's existing account/passkey sign-in. Keep these links private.
|
|
166
166
|
|
|
167
167
|
The complete helper and limits are in `node_modules/@homie-rocks/studio/apps/APPS.md`. Import `appLink`, `qrSvg`, and `randomId` from `@homie-rocks/studio/links` for a ticket URL with parameters. Build customer links from a clean public address, never by copying a staff URL. `randomId` works on plain HTTP.
|
|
168
168
|
|
|
@@ -4218,6 +4218,90 @@
|
|
|
4218
4218
|
}
|
|
4219
4219
|
}
|
|
4220
4220
|
}
|
|
4221
|
+
},
|
|
4222
|
+
"0.58.2": {
|
|
4223
|
+
"files": {
|
|
4224
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
4225
|
+
"README.md": "684b3cca8001673c",
|
|
4226
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
4227
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
4228
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
4229
|
+
"videos/README.md": "16babc3437d5545b",
|
|
4230
|
+
"posts/README.md": "37841aae476bd540",
|
|
4231
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
4232
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
4233
|
+
},
|
|
4234
|
+
"sections": {
|
|
4235
|
+
"AGENTS.md": {
|
|
4236
|
+
"(intro)": "af7f2f732505d379",
|
|
4237
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
4238
|
+
"## Layout": "f3a280b8c96cd068",
|
|
4239
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
4240
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
4241
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
4242
|
+
"## Making games": "bb715272fd1a615b",
|
|
4243
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
4244
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
4245
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
4246
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
4247
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
4248
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
4249
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
4250
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
4251
|
+
"## Beta": "9d02638207a26e73"
|
|
4252
|
+
}
|
|
4253
|
+
},
|
|
4254
|
+
"named": {
|
|
4255
|
+
"Homie Arcade": {
|
|
4256
|
+
"sections": {
|
|
4257
|
+
"AGENTS.md": {
|
|
4258
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4259
|
+
}
|
|
4260
|
+
}
|
|
4261
|
+
}
|
|
4262
|
+
}
|
|
4263
|
+
},
|
|
4264
|
+
"0.58.3": {
|
|
4265
|
+
"files": {
|
|
4266
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
4267
|
+
"README.md": "684b3cca8001673c",
|
|
4268
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
4269
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
4270
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
4271
|
+
"videos/README.md": "16babc3437d5545b",
|
|
4272
|
+
"posts/README.md": "37841aae476bd540",
|
|
4273
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
4274
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
4275
|
+
},
|
|
4276
|
+
"sections": {
|
|
4277
|
+
"AGENTS.md": {
|
|
4278
|
+
"(intro)": "af7f2f732505d379",
|
|
4279
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
4280
|
+
"## Layout": "f3a280b8c96cd068",
|
|
4281
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
4282
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
4283
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
4284
|
+
"## Making games": "bb715272fd1a615b",
|
|
4285
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
4286
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
4287
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
4288
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
4289
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
4290
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
4291
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
4292
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
4293
|
+
"## Beta": "9d02638207a26e73"
|
|
4294
|
+
}
|
|
4295
|
+
},
|
|
4296
|
+
"named": {
|
|
4297
|
+
"Homie Arcade": {
|
|
4298
|
+
"sections": {
|
|
4299
|
+
"AGENTS.md": {
|
|
4300
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4301
|
+
}
|
|
4302
|
+
}
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4221
4305
|
}
|
|
4222
4306
|
}
|
|
4223
4307
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.58.
|
|
3
|
+
"version": "0.58.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/worker/app-records.mjs
CHANGED
|
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS app_roles (
|
|
|
19
19
|
PRIMARY KEY (app, role, player)
|
|
20
20
|
);
|
|
21
21
|
CREATE TABLE IF NOT EXISTS app_role_links (
|
|
22
|
-
app TEXT NOT NULL, role TEXT NOT NULL, token TEXT NOT NULL,
|
|
22
|
+
app TEXT NOT NULL, role TEXT NOT NULL, token TEXT NOT NULL, expires_at INTEGER,
|
|
23
23
|
PRIMARY KEY (app, role)
|
|
24
24
|
);
|
|
25
25
|
`;
|
|
@@ -35,8 +35,37 @@ async function bodyOf(request) {
|
|
|
35
35
|
for (const chunk of chunks) { bytes.set(chunk, at); at += chunk.length; }
|
|
36
36
|
return JSON.parse(new TextDecoder().decode(bytes) || '{}');
|
|
37
37
|
}
|
|
38
|
+
const ROLE_LINK_YEAR = 366 * 24 * 60 * 60 * 1000;
|
|
39
|
+
const roleLinkReady = new WeakSet();
|
|
40
|
+
/** Old studios have no expires_at. One ALTER per database; a duplicate column is the column already being there. */
|
|
41
|
+
async function ensureRoleLinkExpiry(db) {
|
|
42
|
+
if (!db || roleLinkReady.has(db)) return true;
|
|
43
|
+
try {
|
|
44
|
+
await db.prepare('ALTER TABLE app_role_links ADD COLUMN expires_at INTEGER').run();
|
|
45
|
+
roleLinkReady.add(db);
|
|
46
|
+
return true;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (/duplicate column/i.test(String(error?.message ?? error))) { roleLinkReady.add(db); return true; }
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function roleLink(env, app, role) {
|
|
53
|
+
if (await ensureRoleLinkExpiry(env.DB)) {
|
|
54
|
+
try { return await env.DB.prepare('SELECT token, expires_at FROM app_role_links WHERE app = ?1 AND role = ?2').bind(app, role).first(); } catch { /* column still absent */ }
|
|
55
|
+
}
|
|
56
|
+
const row = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(app, role).first();
|
|
57
|
+
return row ? { token: row.token, expires_at: null } : null;
|
|
58
|
+
}
|
|
59
|
+
const linkLive = (row) => !!row && (row.expires_at == null || Number(row.expires_at) > Date.now());
|
|
60
|
+
const linkUrl = (url, meta, role, token) => `${url.origin.replace('127.0.0.1', 'localhost')}/${meta.id}/open?role=${role}&access=${token}`;
|
|
61
|
+
function expiryOf(value) {
|
|
62
|
+
if (value == null) return null;
|
|
63
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
64
|
+
if (!Number.isSafeInteger(n) || n <= Date.now() || n > Date.now() + ROLE_LINK_YEAR) return undefined;
|
|
65
|
+
return n;
|
|
66
|
+
}
|
|
38
67
|
/** Private links conceal staff addresses; a signed-in grant is still required on every request. */
|
|
39
|
-
export async function appAccess(request, env, meta, url) {
|
|
68
|
+
export async function appAccess(request, env, meta, url, options = {}) {
|
|
40
69
|
const role = appRole(meta, url);
|
|
41
70
|
const declared = Object.hasOwn(meta.roles ?? {}, role) ? meta.roles[role] : null;
|
|
42
71
|
if (!declared) return { ok: false, status: 404, error: 'unknown role' };
|
|
@@ -54,8 +83,10 @@ export async function appAccess(request, env, meta, url) {
|
|
|
54
83
|
}
|
|
55
84
|
const handoff=cookieValues(request,`studio_app_${meta.id}_${role}`)[0];
|
|
56
85
|
if(handoff&&await appAssetTicket(env,meta.id,handoff,{meta,purpose:'handoff',expectedRole:role,launch:launchOf(meta,await settingsOf(env),env)}))return {ok:true,role,can:declared.can};
|
|
57
|
-
|
|
58
|
-
if (
|
|
86
|
+
// Opening the app: the signed-in studio owner needs no link. hasRole passes owner:false so a custom route still requires that role's own link.
|
|
87
|
+
if (options.owner !== false && await isOwner(request, env)) return { ok: true, role, can: declared.can };
|
|
88
|
+
const link = await roleLink(env, meta.id, role);
|
|
89
|
+
if (!linkLive(link) || link.token !== url.searchParams.get('access')) return { ok: false, status: 404, error: 'private role link required' };
|
|
59
90
|
if (await isOwner(request, env)) return { ok: true, role, can: declared.can };
|
|
60
91
|
const person = await players.of(request, env);
|
|
61
92
|
if (!person || person.guest) return { ok: false, status: 401, error: 'sign in to this studio' };
|
|
@@ -72,6 +103,17 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
72
103
|
if (request.method !== 'POST') return fail(405, 'POST required');
|
|
73
104
|
if (!key && !sameOrigin(request, url)) return fail(403, 'same origin required');
|
|
74
105
|
const b = await bodyOf(request);
|
|
106
|
+
await ensureRoleLinkExpiry(env.DB);
|
|
107
|
+
if (b.list === true) {
|
|
108
|
+
const roles = [];
|
|
109
|
+
for (const [role, declared] of Object.entries(meta.roles ?? {})) {
|
|
110
|
+
if (!declared?.signIn) continue;
|
|
111
|
+
const row = await roleLink(env, meta.id, role);
|
|
112
|
+
const live = linkLive(row);
|
|
113
|
+
roles.push({ role, expiresAt: row?.expires_at == null ? null : Number(row.expires_at), link: live ? linkUrl(url, meta, role, row.token) : null });
|
|
114
|
+
}
|
|
115
|
+
return json({ ok: true, roles });
|
|
116
|
+
}
|
|
75
117
|
if (!Object.hasOwn(meta.roles, b.role) || !meta.roles[b.role].signIn) return fail(400, 'name a sign-in role');
|
|
76
118
|
if (b.player !== undefined) {
|
|
77
119
|
if (!ID.test(b.player)) return fail(400, 'invalid account');
|
|
@@ -79,11 +121,21 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
79
121
|
if (!p || p.guest) return fail(400, 'role grants require an existing signed-in account');
|
|
80
122
|
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(b.studio === true ? '*' : meta.id, b.role, b.player).run();
|
|
81
123
|
}
|
|
124
|
+
if (b.revokeLink === true) {
|
|
125
|
+
await env.DB.prepare('DELETE FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).run();
|
|
126
|
+
return json({ ok: true, role: b.role, revoked: true });
|
|
127
|
+
}
|
|
128
|
+
let expires;
|
|
129
|
+
if (Object.hasOwn(b, 'expires')) {
|
|
130
|
+
expires = expiryOf(b.expires);
|
|
131
|
+
if (expires === undefined) return fail(400, 'expiry must be a future time within 366 days');
|
|
132
|
+
}
|
|
82
133
|
const token = crypto.randomUUID().replaceAll('-', '');
|
|
83
134
|
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();
|
|
84
135
|
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();
|
|
85
|
-
|
|
86
|
-
|
|
136
|
+
if (expires !== undefined) await env.DB.prepare('UPDATE app_role_links SET expires_at = ?3 WHERE app = ?1 AND role = ?2').bind(meta.id, b.role, expires).run();
|
|
137
|
+
const row = await roleLink(env, meta.id, b.role);
|
|
138
|
+
return json({ ok: true, link: linkUrl(url, meta, b.role, row.token), role: b.role, expiresAt: row.expires_at == null ? null : Number(row.expires_at) });
|
|
87
139
|
}
|
|
88
140
|
const match = /^api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(sub);
|
|
89
141
|
if(!match && /^api\/app\/records\/[^/]+\//.test(sub))return fail(400,'record id: 1–64 letters, digits, underscore or hyphen');
|
package/worker/app-streams.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export async function pushAppRecords(env,cat){
|
|
|
32
32
|
export class AppRecordStreams {
|
|
33
33
|
constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
|
|
34
34
|
dispose(){for(const s of [...this.streams])this.close(s);}
|
|
35
|
-
close(s){if(!this.streams.delete(s))return;
|
|
35
|
+
close(s){if(!this.streams.delete(s))return;clearTimeout(s.timer);clearTimeout(s.reconnect);try{s.controller.close();}catch{/* The reader already cancelled or the stream already closed. */}this.presence();}
|
|
36
36
|
count(s){return [...this.streams].filter(x=>x.app===s.app&&x.room===s.room).length;}
|
|
37
37
|
send(s,data){if(s.controller.desiredSize<=0){this.close(s);return;}try{s.controller.enqueue(frame(data));}catch{this.close(s);}}
|
|
38
38
|
presence(){for(const s of this.streams)this.send(s,{presence:this.count(s)});}
|
|
@@ -56,8 +56,8 @@ export class AppRecordStreams {
|
|
|
56
56
|
for(const other of this.streams)if(other!==s)this.send(other,{presence:this.count(other)});
|
|
57
57
|
// A fresh HTTP response renews the role cookie; SSE frames cannot set cookies.
|
|
58
58
|
s.reconnect=setTimeout(()=>this.close(s),240000);
|
|
59
|
-
//
|
|
60
|
-
s.timer=
|
|
59
|
+
// One recovery check, past eviction, so an open app does not pin its object. The screen reconnects after the cap below.
|
|
60
|
+
s.timer=setTimeout(()=>this.ctx.waitUntil(this.pump(s,true)),180000);
|
|
61
61
|
return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
|
|
62
62
|
}
|
|
63
63
|
async pump(s,recheck=false){
|
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.58.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.58.3","sha256":"e8ea8077d2bad80f19cf3fc5f7a69f0d8ea458f20a880b28442ed23a756944e6"});
|
package/worker/lobby-peak.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {counter,today} from './stats.mjs';
|
|
2
|
+
import {PRESENCE_MS} from './lobby-summary.mjs';
|
|
2
3
|
|
|
3
4
|
// One total per bookkeeping shard, never a sum of independent historical peaks.
|
|
4
5
|
// Reports carry their observation time so a delayed request cannot rewind a shard.
|
|
@@ -32,7 +33,7 @@ export class LobbyPeak {
|
|
|
32
33
|
if(!Number.isInteger(row.shard)||row.shard<0||row.shard>=16||!Number.isFinite(row.at)||!Number.isFinite(row.players)||row.players<0)return Response.json({error:'report'},{status:400});
|
|
33
34
|
if(!prior||row.at>prior.at){
|
|
34
35
|
state.shards[row.shard]={players:row.players,at:row.at};
|
|
35
|
-
const concurrent=Object.values(state.shards).filter(r=>r.at>Date.now()-
|
|
36
|
+
const concurrent=Object.values(state.shards).filter(r=>r.at>Date.now()-PRESENCE_MS).reduce((n,r)=>n+r.players,0);
|
|
36
37
|
const players=Math.max(state.players,concurrent),room=Math.max(state.room,Number(row.room)||0);
|
|
37
38
|
if(players>state.players||room>state.room)this.dirty=true;
|
|
38
39
|
state.players=players;state.room=room;this.subject=new URL(request.url).searchParams.get('game');
|
package/worker/lobby-shards.mjs
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import {roomServer} from './servers.mjs';
|
|
2
|
+
import {liveRoomSummaries} from './lobby-summary.mjs';
|
|
3
|
+
/** Today's high water, from the counter the peak object already wrote. A read must not wake that object. */
|
|
4
|
+
async function peakFromStats(env,game){
|
|
5
|
+
const peak={players:0,room:0};
|
|
6
|
+
if(!env.DB)return peak;
|
|
7
|
+
try{
|
|
8
|
+
const day=new Date().toISOString().slice(0,10);
|
|
9
|
+
const {results}=await env.DB.prepare("SELECT metric, MAX(n) AS n FROM stats_daily WHERE day=?1 AND subject=?2 AND metric IN ('peak','peak-room') GROUP BY metric").bind(day,game).all();
|
|
10
|
+
for(const row of results??[]){
|
|
11
|
+
if(row.metric==='peak')peak.players=Number(row.n)||0;
|
|
12
|
+
if(row.metric==='peak-room')peak.room=Number(row.n)||0;
|
|
13
|
+
}
|
|
14
|
+
}catch{/* before the stats table, the number is zero and the peak object stays asleep */}
|
|
15
|
+
return peak;
|
|
16
|
+
}
|
|
2
17
|
export const LOBBY_SHARDS=16;
|
|
3
18
|
export const SHARDED_ROOM_BASE=100000000;
|
|
4
19
|
export function lobbyShard(key) {
|
|
@@ -28,6 +43,19 @@ export function lobbyFor(env,game,identity='') {
|
|
|
28
43
|
const body=JSON.parse(init?.body??await input.clone().text());
|
|
29
44
|
return send(roomShard(body.room),url,init??{method:'POST',body:JSON.stringify(body)});
|
|
30
45
|
}
|
|
46
|
+
// A read must not wake every shard. The shards write lobby_summaries when they take a report; one D1 read answers the office, the stats page and the lounge. /busiest still asks the shards: a watcher is matched on the live build, which the summary does not carry.
|
|
47
|
+
if(url.pathname==='/now'||url.pathname==='/rooms'||url.pathname==='/office') {
|
|
48
|
+
const summaries=await liveRoomSummaries(env);
|
|
49
|
+
if(summaries){
|
|
50
|
+
const list=summaries.get(game)??[];
|
|
51
|
+
if(url.pathname==='/rooms')return Response.json({rooms:list});
|
|
52
|
+
if(url.pathname==='/office'){
|
|
53
|
+
const rooms=list.map(r=>({room:r.name??r.room,players:r.players||0,people:r.people??r.players??0,agents:r.agents??0,ai:r.ai??0,at:r.at,server:r.server??roomServer(r.name??r.room),...(r.closedUntil?{closedUntil:r.closedUntil}:{})})).filter(r=>(r.players||0)>0||r.closedUntil).sort((a,b)=>b.players-a.players).slice(0,100);
|
|
54
|
+
return Response.json({rooms});
|
|
55
|
+
}
|
|
56
|
+
return Response.json({players:list.reduce((n,r)=>n+(Number(r.players)||0),0),rooms:list.filter(r=>(r.players||0)>0).length,peak:await peakFromStats(env,game)});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
31
59
|
const rows=await Promise.all(Array.from({length:LOBBY_SHARDS},async(_,shard)=>{
|
|
32
60
|
const response=await send(shard,url,init);if(!response.ok)throw Error('Lobby shard unavailable');return response.json();
|
|
33
61
|
}));
|
package/worker/lobby-summary.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** How long a room still counts as live after its last report. Longer than the lobby heartbeat, so a quiet room stays listed while its shard hibernates. */
|
|
2
|
+
export const PRESENCE_MS=240000;
|
|
1
3
|
export const LIVE_ROOMS_QUERY='SELECT game,rooms,updated FROM lobby_summaries ORDER BY game LIMIT 500';
|
|
2
4
|
/** Queue order is not guaranteed. An older snapshot must never replace a newer one. */
|
|
3
5
|
export async function storeLobbySummary(env, message) {
|
|
@@ -10,7 +12,7 @@ export async function storeLobbySummary(env, message) {
|
|
|
10
12
|
}
|
|
11
13
|
export async function liveRoomSummaries(env) {
|
|
12
14
|
if(!env.BACKGROUND||!env.DB)return null; // Older wrappers still query their Lobby.
|
|
13
|
-
const result=new Map(),since=Date.now()-
|
|
15
|
+
const result=new Map(),since=Date.now()-PRESENCE_MS;
|
|
14
16
|
let cursor=null;
|
|
15
17
|
for(;;){
|
|
16
18
|
const query=cursor===null?env.DB.prepare(LIVE_ROOMS_QUERY):env.DB.prepare('SELECT game,rooms,updated FROM lobby_summaries WHERE game>? ORDER BY game LIMIT 500').bind(cursor);
|
package/worker/lobby.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { ROOM_ID } from './page-policy.mjs';
|
|
|
8
8
|
import { SERVER_ID, pooledRoom, roomCode, roomServer } from './servers.mjs';
|
|
9
9
|
import { seatCount } from './seats.mjs';
|
|
10
10
|
import { counter, today } from './stats.mjs';
|
|
11
|
+
import { PRESENCE_MS } from './lobby-summary.mjs';
|
|
11
12
|
|
|
12
13
|
import {LOBBY_APP_VERSIONS, LOBBY_ROOMS_MAX} from './lobby-limits.mjs';
|
|
13
14
|
export {LOBBY_APP_VERSIONS, LOBBY_ROOMS_MAX} from './lobby-limits.mjs';
|
|
@@ -34,7 +35,7 @@ export class Lobby {
|
|
|
34
35
|
this.current = saved.current ?? null;
|
|
35
36
|
this.next = typeof saved.next === 'number' ? { public: saved.next } : saved.next && typeof saved.next === 'object' ? { public: 1, ...saved.next } : { public: 1 };
|
|
36
37
|
for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, server: r.server ?? roomServer(r.name), agents: r.agents ?? 0, ai: r.ai ?? 0, ver: typeof r.ver === 'string' ? r.ver : '', pending: [], agentPending: [] });
|
|
37
|
-
for(const r of this.rooms.values())if(r.players>0&&r.at>Date.now()-
|
|
38
|
+
for(const r of this.rooms.values())if(r.players>0&&r.at>Date.now()-PRESENCE_MS)this.live.set(r.name,{players:r.players,at:r.at});
|
|
38
39
|
if(ctx.storage.sql)for(const row of ctx.storage.sql.exec('SELECT body,expires FROM matchmaking_reservations WHERE expires>?',Date.now()).toArray()) {
|
|
39
40
|
const reservation=JSON.parse(row.body),room=this.rooms.get(reservation.room);
|
|
40
41
|
if(room && reservation.pending!==false)room.pending.push(row.expires-20000);
|
|
@@ -51,7 +52,7 @@ export class Lobby {
|
|
|
51
52
|
notePeak(room, players, game) {
|
|
52
53
|
const now = Date.now();
|
|
53
54
|
if (players > 0) this.live.set(room, { players, at: now }); else this.live.delete(room);
|
|
54
|
-
for (const [name, r] of this.live) if (now - r.at >
|
|
55
|
+
for (const [name, r] of this.live) if (now - r.at > PRESENCE_MS) this.live.delete(name); // a room that stopped reporting
|
|
55
56
|
const day = today();
|
|
56
57
|
if (this.peak.day !== day) this.peak = { day, players: 0, room: 0 };
|
|
57
58
|
let all = 0;
|
|
@@ -99,7 +100,11 @@ export class Lobby {
|
|
|
99
100
|
}
|
|
100
101
|
try {
|
|
101
102
|
const response=await this.answer(request),url=new URL(request.url);
|
|
102
|
-
if(response.ok && request.method==='POST' && url.pathname==='/report' && this.env?.BACKGROUND && url.searchParams.get('game'))
|
|
103
|
+
if(response.ok && request.method==='POST' && url.pathname==='/report' && this.env?.BACKGROUND && url.searchParams.get('game')) {
|
|
104
|
+
const game=url.searchParams.get('game'),shard=Number(url.searchParams.get('shard'))||0;
|
|
105
|
+
// Publish in this wake. A +10s alarm restarts the idle clock and, with a heartbeat inside the eviction window, pins the shard for as long as anyone is seated.
|
|
106
|
+
this.ctx.waitUntil(this.publishSummary(game,shard).catch(error=>console.error(JSON.stringify({ev:'lobby-summary-failed',error:errorLine(error)}))));
|
|
107
|
+
}
|
|
103
108
|
return response;
|
|
104
109
|
} catch (error) {
|
|
105
110
|
let path = '';
|
|
@@ -109,24 +114,22 @@ export class Lobby {
|
|
|
109
114
|
}
|
|
110
115
|
}
|
|
111
116
|
|
|
112
|
-
async
|
|
113
|
-
this.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
117
|
+
async publishSummary(game,shard=0) {
|
|
118
|
+
if(!game||!this.env?.BACKGROUND)return;
|
|
119
|
+
const at=Date.now();
|
|
120
|
+
const rooms=[...this.rooms.values()].filter(r=>r.players>0&&r.at>at-PRESENCE_MS).map(({name,players,agents,ai,server,at:reported})=>({name,players,agents:agents??0,ai:ai??0,server:server??roomServer(name),at:reported}));
|
|
121
|
+
for(const [name,until] of this.closed)if(until>at&&!rooms.some(r=>r.name===name))rooms.push({name,players:0,agents:0,ai:0,server:roomServer(name),at,closedUntil:until});
|
|
122
|
+
await this.env.BACKGROUND.send({v:1,type:'lobby.snapshot',game,shard,at,rooms});
|
|
119
123
|
}
|
|
120
124
|
async alarm() {
|
|
121
125
|
if(this.matchRouter)return this.matchRouter.alarm();
|
|
122
126
|
if((await this.ctx.storage.list?.({prefix:'match:',limit:1}))?.size)return (this.matchRouter??=new MatchRouter(this.ctx,this.env)).alarm();
|
|
127
|
+
// A summary alarm armed by an older build. Publish once and do not arm another: an idle shard has to be able to hibernate.
|
|
123
128
|
this.summaryDue=null;
|
|
124
129
|
const saved=await this.ctx.storage.get('summary-game');
|
|
125
130
|
const game=this.summaryGame??(typeof saved==='string'?saved:saved?.game),shard=this.summaryShard??saved?.shard??0;
|
|
126
|
-
if(
|
|
127
|
-
|
|
128
|
-
const rooms=[...this.rooms.values()].filter(r=>r.players>0&&r.at>at-180000).map(({name,players,agents,ai,server,at})=>({name,players,agents:agents??0,ai:ai??0,server:server??roomServer(name),at}));
|
|
129
|
-
await this.env.BACKGROUND.send({v:1,type:'lobby.snapshot',game,shard,at,rooms});
|
|
131
|
+
if(saved)await this.ctx.storage.delete('summary-game').catch(()=>{});
|
|
132
|
+
await this.publishSummary(game,shard);
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
reservation(key,result) {
|
|
@@ -303,7 +306,7 @@ export class Lobby {
|
|
|
303
306
|
}
|
|
304
307
|
if (url.pathname === '/office') {
|
|
305
308
|
// The owner's office: every room with somebody seated in the last 3 minutes, public or named, and closed ones.
|
|
306
|
-
for (const [name, r] of this.seen) if (now - r.at >
|
|
309
|
+
for (const [name, r] of this.seen) if (now - r.at > PRESENCE_MS) this.seen.delete(name);
|
|
307
310
|
const rooms = new Map([...this.seen.values()].map((r) => [r.room, r]));
|
|
308
311
|
for (const r of this.rooms.values()) if (r.players > 0 && !rooms.has(r.name)) rooms.set(r.name, { room: r.name, players: r.players, people: r.players, agents: r.agents ?? 0, ai: r.ai ?? 0, at: r.at });
|
|
309
312
|
for (const [name, until] of this.closed) if (until > now && !rooms.has(name)) rooms.set(name, { room: name, players: 0, people: 0, at: now, closedUntil: until });
|
|
@@ -311,7 +314,7 @@ export class Lobby {
|
|
|
311
314
|
}
|
|
312
315
|
if (url.pathname === '/now') {
|
|
313
316
|
let players = 0;
|
|
314
|
-
for (const [name, r] of this.live) { if (Date.now() - r.at >
|
|
317
|
+
for (const [name, r] of this.live) { if (Date.now() - r.at > PRESENCE_MS) this.live.delete(name); else players += r.players; }
|
|
315
318
|
return json({ players, rooms: this.live.size, peak: this.peak.day === today() ? { players: this.peak.players, room: this.peak.room } : { players: 0, room: 0 } });
|
|
316
319
|
}
|
|
317
320
|
return json({ error: 'not found' }, 404);
|
|
@@ -4,7 +4,7 @@ import {EVENT_ERRORS,terminalEventError,EventsError,eventError,notification,sseF
|
|
|
4
4
|
/** Connection-scoped push subscriptions hosted by the existing per-connection Lobby. */
|
|
5
5
|
export class McpEventStreams {
|
|
6
6
|
constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
|
|
7
|
-
close(stream){if(stream.closed)return;stream.closed=true;
|
|
7
|
+
close(stream){if(stream.closed)return;stream.closed=true;clearTimeout(stream.timer);clearTimeout(stream.life);stream.resume?.();try{stream.controller.close();}catch{/* The reader already cancelled or the stream already closed. */}this.streams.delete(stream);if(stream.registryKey)this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(stream.registryKey).run().catch(error=>console.warn("MCP stream cleanup deferred to expiry:",error.message)));}
|
|
8
8
|
async write(stream,value,authorize){
|
|
9
9
|
if(stream.closed)return false;
|
|
10
10
|
if(stream.controller.desiredSize<=0)await new Promise(resolve=>{stream.resume=resolve;});
|
|
@@ -23,8 +23,9 @@ export class McpEventStreams {
|
|
|
23
23
|
const stream={...input,registryKey,source:eventSource(feed),cursor:cursorOf(input.params.name,start.after),closed:false,busy:false};
|
|
24
24
|
const body=new ReadableStream({start:controller=>{stream.controller=controller;controller.enqueue(sseFrame(notification('active',{cursor:stream.cursor,truncated:start.truncated},stream.id)));},pull:()=>{const resume=stream.resume;stream.resume=null;resume?.();},cancel:()=>this.close(stream)});
|
|
25
25
|
this.streams.add(stream);
|
|
26
|
-
//
|
|
27
|
-
stream.timer=
|
|
26
|
+
// One heartbeat is enough to recheck authorization. A repeating timer, shorter than eviction, pins this lobby for as long as the page holds the stream.
|
|
27
|
+
stream.timer=setTimeout(()=>this.pump(stream),180000);
|
|
28
|
+
stream.life=setTimeout(()=>this.close(stream),300000);
|
|
28
29
|
this.pump(stream);
|
|
29
30
|
return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
|
|
30
31
|
}catch(error){return Response.json({jsonrpc:'2.0',id:input.id,error:eventError(error)});}
|
package/worker/mcp-events.mjs
CHANGED
|
@@ -45,7 +45,7 @@ export async function pollEvents(env,cat,caller,params,now=Date.now()){
|
|
|
45
45
|
const hasMore=rows.length>max,page=rows.slice(0,max);
|
|
46
46
|
const events=page.map(row=>{const data=JSON.parse(row.data);if((env.HOMIE_LOCAL_TEST_SECRET||caller.remoteTest)&&caller.localTest&&caller.testScope&&feed.event==='record.changed')data.app=feed.app;return occurrence(params.name,{...row,data},cursorOf(params.name,row.seq));});
|
|
47
47
|
const after=hasMore?page.at(-1).seq:start.through;
|
|
48
|
-
return {events,cursor:cursorOf(params.name,after),truncated:start.truncated,hasMore,nextPollMs:
|
|
48
|
+
return {events,cursor:cursorOf(params.name,after),truncated:start.truncated,hasMore,nextPollMs:180000};
|
|
49
49
|
}
|
|
50
50
|
async function subId(caller,params,url){const bytes=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify([caller.id,url,params.name,{}])));return 'sub_'+Array.from(new Uint8Array(bytes),b=>b.toString(16).padStart(2,'0')).join('');}
|
|
51
51
|
async function stored(env,id){const row=await env.DB.prepare('SELECT value FROM mcp_kv WHERE key=?1').bind(prefix+id).first();return row?JSON.parse(row.value):null;}
|
package/worker/route-auth.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export async function hasRole(request, env, app, role) {
|
|
|
13
13
|
const launch = launchOf(meta, await settingsOf(env, {fresh:true}), env);
|
|
14
14
|
if (launch !== 'public' && !(await accessOf(request, env, app, launch)).ok) return false;
|
|
15
15
|
url.searchParams.set('role', role);
|
|
16
|
-
return (await (await import('./app-records.mjs')).appAccess(request, env, meta, url)).ok === true;
|
|
16
|
+
return (await (await import('./app-records.mjs')).appAccess(request, env, meta, url, { owner: false })).ok === true;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/** Return a refusal response, or null when the browser supplied same-origin evidence. */
|
|
@@ -429,8 +429,12 @@ async function serverLive(env, meta, servers, origin, lobby) {
|
|
|
429
429
|
async function weekOf(env, game) {
|
|
430
430
|
const w = await playedThisWeek(env, game);
|
|
431
431
|
if (!w) return null;
|
|
432
|
+
// Today's high water is already in stats_daily. Reading the peak object here woke it on every 15s live poll, which is shorter than eviction, so the object never slept while a game page was open.
|
|
432
433
|
let peak = 0;
|
|
433
|
-
try {
|
|
434
|
+
try {
|
|
435
|
+
const row = await env.DB.prepare("SELECT MAX(n) AS n FROM stats_daily WHERE day = ?1 AND metric = 'peak' AND subject = ?2").bind(today(), String(game)).first();
|
|
436
|
+
peak = Number(row?.n) || 0;
|
|
437
|
+
} catch { peak = 0; }
|
|
434
438
|
return { plays: w.plays, rounds: w.rounds, ...(peak ? { peak } : {}) };
|
|
435
439
|
}
|
|
436
440
|
|
package/worker/stats.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {lobbyFor} from './lobby-shards.mjs';
|
|
2
|
+
import {liveRoomSummaries} from './lobby-summary.mjs';
|
|
2
3
|
import {writePageMetric,analyticsStatus} from './analytics.mjs';
|
|
3
4
|
/**
|
|
4
5
|
* STUDIO METRICS: count, don't track (@homie-rocks/studio 0.6.0).
|
|
@@ -251,8 +252,17 @@ export async function playersNow(env) {
|
|
|
251
252
|
} catch { return null; }
|
|
252
253
|
}
|
|
253
254
|
|
|
254
|
-
/**
|
|
255
|
+
/** People in every room of each game, right now. The summary table when this worker has one, so a stats read does not wake a lobby shard. */
|
|
255
256
|
async function playingNow(env, games) {
|
|
257
|
+
const summaries = await liveRoomSummaries(env).catch(() => null);
|
|
258
|
+
if (summaries) {
|
|
259
|
+
const out = {};
|
|
260
|
+
for (const g of games) {
|
|
261
|
+
const list = summaries.get(g.id) ?? [];
|
|
262
|
+
out[g.id] = { players: list.reduce((n, r) => n + (Number(r.players) || 0), 0), rooms: list.filter((r) => (r.players || 0) > 0).length };
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
256
266
|
const out = {};
|
|
257
267
|
await Promise.all(games.map(async (g) => {
|
|
258
268
|
try {
|
package/worker/table.mjs
CHANGED
|
@@ -1447,8 +1447,8 @@ export class Table {
|
|
|
1447
1447
|
// People for the stats: seated sockets that are not house QA (players is what the Lobby matches strangers by).
|
|
1448
1448
|
let people = 0;
|
|
1449
1449
|
for (const c of room.clients.values()) if (c.helloed && c.seat !== null && !c.agent && !c.conn?.qa) people += 1;
|
|
1450
|
-
// On a change, and
|
|
1451
|
-
if (players === this.lastReport && people === this.lastPeople && agents === this.lastAgents && ai === this.lastAi && (!players || Date.now() - (this.lastReportAt ?? 0) <
|
|
1450
|
+
// On a change, and every two and a half minutes while anybody is here. That is longer than Durable Object eviction, so an occupied room's lobby shard hibernates between heartbeats, and shorter than the presence window, so "playing now" does not drop a live room.
|
|
1451
|
+
if (players === this.lastReport && people === this.lastPeople && agents === this.lastAgents && ai === this.lastAi && (!players || Date.now() - (this.lastReportAt ?? 0) < 150_000)) return;
|
|
1452
1452
|
this.lastReport = players;
|
|
1453
1453
|
this.lastPeople = people;
|
|
1454
1454
|
this.lastAgents = agents;
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.58.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.58.3';
|