@homie-rocks/studio 0.58.1 → 0.58.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -17,6 +17,21 @@ 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.2] - 2026-10-17
21
+
22
+ **Plugin 0.58.2**
23
+
24
+ The studio owner opens a private app role. A staff link can expire, and it can be listed or revoked on its own.
25
+
26
+ ### Added
27
+
28
+ - A signed-in studio owner opens a private app role with the owner session. Everyone else still needs that role's link.
29
+ - `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.
30
+
31
+ ### Upgrade notes
32
+
33
+ - Upgrade the studio to pick this up. A link issued before this version has no expiry until one is set.
34
+
20
35
  ## [0.58.1] - 2026-10-17
21
36
 
22
37
  **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
 
@@ -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
- if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(id ?? '') || !/^[a-z][a-z0-9-]{0,39}$/.test(role ?? '')) throw new Error('app role <id> <role> [--player <account>] [--revoke] [--url <site>]');
954
- 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') }));
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') });
@@ -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,48 @@
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
+ }
4221
4263
  }
4222
4264
  }
4223
4265
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.58.1",
3
+ "version": "0.58.2",
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",
@@ -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
- const link = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, role).first();
58
- if (!link || link.token !== url.searchParams.get('access')) return { ok: false, status: 404, error: 'private role link required' };
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
- const row = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).first();
86
- return json({ ok: true, link: `${url.origin.replace('127.0.0.1','localhost')}/${meta.id}/open?role=${b.role}&access=${row.token}`, role: b.role });
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');
@@ -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.1","sha256":"b3944d94ae64b4e7755614a9801c54216f14fcb741e9598bd5c4eab117deae50"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.58.2","sha256":"5186ba06317a7de1677209dbbba72ee69d4e1cf79a08b46c74e9c20bd92e74a7"});
@@ -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. */
@@ -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.1';
2
+ export const STUDIO_VERSION_TAG = '0.58.2';