@homie-rocks/studio 0.11.0 → 0.12.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.
@@ -0,0 +1,350 @@
1
+ /**
2
+ * /account/ — a player's account on this studio: make one with a passkey, sign in on another device, add a
3
+ * passkey, a name, a recovery email (when the studio has a mail sender), download everything, delete everything.
4
+ * Drawn in the studio's own look (worker/site.mjs layout); the page's one script is /_homie/account.js (the
5
+ * site's CSP allows no inline script). PASSKEY_JS is shared with the play shell's sign-in sheet.
6
+ */
7
+ import { esc, layout } from './site.mjs';
8
+
9
+ /**
10
+ * The browser half of a passkey ceremony, as plain ES2017 (the account page and the play shell both carry it):
11
+ * homiePasskey.create(options) / .get(options) turn the server's JSON options into WebAuthn calls and the
12
+ * credential back into JSON; homiePasskey.api(method, path, body) talks to /api/player/.
13
+ */
14
+ export const PASSKEY_JS = String.raw`var homiePasskey = (function () {
15
+ function toBuf(s) { s = String(s).replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; var b = atob(s), u = new Uint8Array(b.length); for (var i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); return u.buffer; }
16
+ function toB64u(buf) { var u = new Uint8Array(buf), s = ''; for (var i = 0; i < u.length; i++) s += String.fromCharCode(u[i]); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
17
+ function supported() { return !!(window.PublicKeyCredential && navigator.credentials && navigator.credentials.create); }
18
+ function api(method, path, body) {
19
+ var init = { method: method, credentials: 'same-origin', cache: 'no-store', headers: {} };
20
+ if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
21
+ return fetch(path, init).then(function (r) { return r.json().catch(function () { return { ok: false, error: 'http', message: 'the site answered ' + r.status }; }).then(function (j) { j.status = r.status; return j; }); });
22
+ }
23
+ function create(o) {
24
+ var pk = { challenge: toBuf(o.challenge), rp: o.rp, user: { id: toBuf(o.user.id), name: o.user.name, displayName: o.user.displayName },
25
+ pubKeyCredParams: o.pubKeyCredParams, authenticatorSelection: o.authenticatorSelection, attestation: o.attestation, timeout: o.timeout,
26
+ excludeCredentials: (o.excludeCredentials || []).map(function (c) { return { type: 'public-key', id: toBuf(c.id) }; }) };
27
+ return navigator.credentials.create({ publicKey: pk }).then(function (c) {
28
+ return { id: c.id, rawId: toB64u(c.rawId), type: c.type, response: { clientDataJSON: toB64u(c.response.clientDataJSON), attestationObject: toB64u(c.response.attestationObject),
29
+ transports: c.response.getTransports ? c.response.getTransports() : [] } };
30
+ });
31
+ }
32
+ function get(o) {
33
+ var pk = { challenge: toBuf(o.challenge), rpId: o.rpId, userVerification: o.userVerification, timeout: o.timeout, allowCredentials: [] };
34
+ return navigator.credentials.get({ publicKey: pk }).then(function (c) {
35
+ return { id: c.id, rawId: toB64u(c.rawId), type: c.type, response: { clientDataJSON: toB64u(c.response.clientDataJSON), authenticatorData: toB64u(c.response.authenticatorData),
36
+ signature: toB64u(c.response.signature), userHandle: c.response.userHandle ? toB64u(c.response.userHandle) : null } };
37
+ });
38
+ }
39
+ /** Options fetched ahead (a click must reach the passkey prompt at once: Safari wants the user's tap). */
40
+ var held = {};
41
+ function prime(kind, path, body) { held[kind] = { at: Date.now(), p: api('POST', path, body || {}) }; return held[kind].p; }
42
+ function options(kind, path, body) { var h = held[kind]; delete held[kind]; return h && Date.now() - h.at < 240000 ? h.p : api('POST', path, body || {}); }
43
+ function said(e) {
44
+ var n = e && e.name;
45
+ if (n === 'NotAllowedError' || n === 'AbortError') return 'No passkey was used. Try again when you are ready.';
46
+ if (n === 'InvalidStateError') return 'This device already has a passkey for this account.';
47
+ if (n === 'SecurityError') return /^[0-9.]+$|^\[/.test(location.hostname) ? 'Passkeys need a name, not a number: open http://localhost:' + location.port + location.pathname + ' instead.' : 'Passkeys need this site on https (or http://localhost while developing).';
48
+ return (e && e.message) || 'That did not work.';
49
+ }
50
+ function remember(p) { try { if (p) localStorage.setItem('homie.player', JSON.stringify({ id: p.id, name: p.name, guest: p.guest, owner: p.owner })); else localStorage.removeItem('homie.player'); } catch (e) {} }
51
+ /** Make an account (or add a passkey to the guest or account this browser has): the player, or an error. */
52
+ function signUp(name) {
53
+ return options('up', '/api/player/signup/options').then(function (r) {
54
+ if (!r.ok) throw new Error(r.message || r.error);
55
+ return create(r.options);
56
+ }).then(function (cred) { return api('POST', '/api/player/signup', { credential: cred, name: name || undefined, label: '' }); })
57
+ .then(function (r) { if (!r.ok) throw new Error(r.message || r.error); remember(r.player); return r; });
58
+ }
59
+ function signIn() {
60
+ return options('in', '/api/player/signin/options').then(function (r) {
61
+ if (!r.ok) throw new Error(r.message || r.error);
62
+ return get(r.options);
63
+ }).then(function (cred) {
64
+ return api('POST', '/api/player/signin', { credential: cred }).then(function (r) {
65
+ if (!r.ok && r.error === 'unknown' && window.PublicKeyCredential && PublicKeyCredential.signalUnknownCredential) {
66
+ // Tell the device this site no longer knows that passkey, so it stops offering it.
67
+ try { PublicKeyCredential.signalUnknownCredential({ rpId: location.hostname, credentialId: cred.id }).catch(function () {}); } catch (e) {}
68
+ }
69
+ if (!r.ok) throw new Error(r.message || r.error);
70
+ remember(r.player); return r;
71
+ });
72
+ });
73
+ }
74
+ return { api: api, create: create, get: get, prime: prime, options: options, said: said, remember: remember, signUp: signUp, signIn: signIn, supported: supported };
75
+ }());`;
76
+
77
+ const ACCOUNT_CSS = `
78
+ .acct{max-width:760px;margin:0 auto;padding:clamp(28px,6vw,72px) max(var(--gutter),16px) 24px}
79
+ .acct h1{margin:0;font:800 clamp(34px,6vw,60px)/1 var(--display);letter-spacing:-.03em}
80
+ .acct .lead{margin-top:14px}
81
+ .acct .card{margin-top:22px;padding:clamp(18px,3vw,26px);border-radius:var(--r);background:var(--panel);border:1px solid var(--line)}
82
+ .acct .card h2{margin:0 0 6px;font:800 22px/1.2 var(--display)}
83
+ .acct .card p{margin:6px 0 0;color:var(--soft)}
84
+ .acct .row{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:16px}
85
+ .acct label{display:block;font:600 13px/1.4 var(--text);color:var(--dim);margin-top:14px}
86
+ .acct input{box-sizing:border-box;width:100%;max-width:340px;min-height:48px;margin-top:6px;padding:0 14px;border-radius:12px;border:1px solid var(--line);background:var(--bg);color:var(--fg);font:500 17px/1 var(--text)}
87
+ .acct input:focus{outline:2px solid var(--hot);outline-offset:1px}
88
+ .acct button.btn,.acct button.ghost{cursor:pointer;border:0}
89
+ .acct button.ghost{border:1px solid color-mix(in srgb,var(--fg) 30%,transparent)}
90
+ .acct .who{display:flex;align-items:center;gap:14px;margin-top:8px}
91
+ .acct .who b{font:800 26px/1.1 var(--display)}
92
+ .acct .badge{display:inline-block;padding:3px 9px;border-radius:999px;font:700 12px/1.4 var(--mono);letter-spacing:.06em;text-transform:uppercase;background:color-mix(in srgb,var(--hot) 22%,transparent);color:var(--fg)}
93
+ .acct ul.keys-list{list-style:none;margin:12px 0 0;padding:0}
94
+ .acct ul.keys-list li{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:10px 0;border-top:1px solid var(--line);color:var(--soft)}
95
+ .acct ul.keys-list li button{min-height:36px;padding:0 12px;font-size:14px}
96
+ .acct .msg{min-height:1.5em;margin-top:16px;font-weight:600}
97
+ .acct .msg.bad{color:#ff8a80}
98
+ .acct .msg.good{color:#8fe3a8}
99
+ .acct .danger{color:#ff8a80;border-color:color-mix(in srgb,#ff8a80 50%,transparent)!important}
100
+ .acct details{margin-top:22px;color:var(--soft)}
101
+ .acct details summary{cursor:pointer;font-weight:700;color:var(--fg)}
102
+ .acct details li{margin:6px 0}
103
+ .acct .back{margin-top:18px}
104
+ `;
105
+
106
+ /** The page. `next` is where "Back to the game" goes; `owner` an owner-claim key; `link` + `mode` a mailed link. */
107
+ export function accountPage(cat, { origin = '', next = '/account/', owner = null, link = null, mode = null, features = {} } = {}) {
108
+ const studio = cat?.studio?.name ?? 'Studio';
109
+ const boot = { next, owner, link, mode, features, studio };
110
+ const games = (cat?.games ?? []).filter((g) => g.saves).map((g) => g.name);
111
+ const back = next !== '/account/' ? `<p class="back"><a class="ghost" href="${esc(next)}" data-back>Back to the game</a></p>` : '';
112
+ const main = `<section class="acct" data-account>
113
+ <p class="kicker">${esc(studio)}</p>
114
+ <h1>Your player account</h1>
115
+ <p class="lead">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
+ <noscript><p class="lead">This page needs JavaScript for passkeys.</p></noscript>
117
+ <div class="card" data-view="loading"><p>Looking for your account…</p></div>
118
+
119
+ <div class="card" data-view="out" hidden>
120
+ <h2>Make an account</h2>
121
+ <p>Your device makes a passkey for this site and keeps it in its own keychain, so it signs you in on your other devices too.</p>
122
+ <label for="acct-name">A name other players see (you can change it later)</label>
123
+ <input id="acct-name" data-name maxlength="24" autocomplete="nickname" placeholder="Leave empty for a random one">
124
+ <div class="row"><button class="btn" type="button" data-make>Make an account</button><button class="ghost" type="button" data-signin>I have one: sign in</button></div>
125
+ ${features.email ? '<p class="small"><a href="#recover" data-show-recover>Lost your passkeys?</a></p><div data-recover hidden><label for="acct-recover">The recovery email on your account</label><input id="acct-recover" data-recover-email type="email" autocomplete="email"><div class="row"><button class="ghost" type="button" data-recover-send>Email me a link</button></div></div>' : ''}
126
+ </div>
127
+
128
+ <div class="card" data-view="guest" hidden>
129
+ <h2>You are playing as a guest</h2>
130
+ <div class="who"><b data-me-name></b></div>
131
+ <p>Your progress is kept in this browser only. Make an account and it stays yours on every device; nothing you have is lost.</p>
132
+ <label for="acct-gname">Your name (optional)</label>
133
+ <input id="acct-gname" data-gname maxlength="24" autocomplete="nickname">
134
+ <div class="row"><button class="btn" type="button" data-upgrade>Make an account</button><button class="ghost" type="button" data-signin>Sign in to my account</button></div>
135
+ <p class="small">Signing in to an account you already have keeps that account's saves, and brings over anything only this guest had.</p>
136
+ </div>
137
+
138
+ <div data-view="in" hidden>
139
+ <div class="card">
140
+ <h2>Signed in</h2>
141
+ <div class="who"><b data-me-name></b><span class="badge" data-owner-badge hidden>Owner</span></div>
142
+ <label for="acct-rename">Name other players see</label>
143
+ <input id="acct-rename" data-rename maxlength="24" autocomplete="nickname">
144
+ <div class="row"><button class="ghost" type="button" data-save-name>Save name</button><button class="ghost" type="button" data-signout>Sign out</button></div>
145
+ </div>
146
+ <div class="card" data-owner-claim hidden>
147
+ <h2>Is this the studio owner's account?</h2>
148
+ <p>Your AI made a one-time link to mark your own account as the owner's, so your games and this studio's back office know it is you.</p>
149
+ <div class="row"><button class="btn" type="button" data-claim>Yes, this account is mine</button></div>
150
+ </div>
151
+ <div class="card">
152
+ <h2>Passkeys</h2>
153
+ <p>Each one signs you in. Your device's own sync (iCloud Keychain, Google Password Manager, your password manager) carries a passkey to your other devices. Add one here on a device that does not have it, or a security key, so you can always get back in.</p>
154
+ <ul class="keys-list" data-keys></ul>
155
+ <div class="row"><button class="ghost" type="button" data-add>Add a passkey on this device</button></div>
156
+ </div>
157
+ ${features.email ? `<div class="card" data-email-card>
158
+ <h2>Recovery email</h2>
159
+ <p data-email-state>If you lose every passkey, a link to this address lets you make a new one. Optional.</p>
160
+ <label for="acct-email">Email</label><input id="acct-email" data-email type="email" autocomplete="email">
161
+ <div class="row"><button class="ghost" type="button" data-email-save>Send a confirmation</button><button class="ghost" type="button" data-email-remove hidden>Remove it</button></div>
162
+ </div>` : ''}
163
+ <div class="card">
164
+ <h2>Your data</h2>
165
+ <p>Download everything this studio keeps about you (your saves in every game, your stats, your memorials), or delete it all.</p>
166
+ <div class="row"><a class="ghost" href="/api/player/export" download>Download my data</a><button class="ghost danger" type="button" data-delete>Delete my account</button></div>
167
+ </div>
168
+ </div>
169
+
170
+ <div class="card" data-view="link" hidden>
171
+ <h2 data-link-title></h2>
172
+ <p data-link-text></p>
173
+ <div class="row"><button class="btn" type="button" data-link-go></button></div>
174
+ </div>
175
+
176
+ <p class="msg" data-msg role="status" aria-live="polite"></p>
177
+ ${back}
178
+ <details>
179
+ <summary>What ${esc(studio)} keeps, and what it never does</summary>
180
+ <ul>
181
+ <li>A random player id and the name you choose (or a two-word one made for you).</li>
182
+ <li>Each passkey's <em>public</em> key and when it was last used. The private key never leaves your device.</li>
183
+ <li>Your saves, stats and memorials in ${games.length ? esc(games.join(', ')) : 'this studio\'s games'}.</li>
184
+ <li>A recovery email only if you add one. No password. No IP address. No tracking cookies, and nothing is sent to anyone else.</li>
185
+ <li>The studio's owner can see how many players there are and their names, never your passkeys or email.</li>
186
+ <li>A guest that nobody plays for 180 days is forgotten with everything it kept.</li>
187
+ </ul>
188
+ </details>
189
+ </section>
190
+ <script type="application/json" id="account-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
191
+ <script src="/_homie/account.js" defer></script>`;
192
+ return layout(cat, { title: `Your account · ${studio}`, description: `Your player account on ${studio}.`, origin, path: '/account/', page: 'account', head: `<style>${ACCOUNT_CSS}</style><meta name="robots" content="noindex">`, main });
193
+ }
194
+
195
+ /** /_homie/account.js */
196
+ export const ACCOUNT_JS = `${PASSKEY_JS}
197
+ (function () {
198
+ 'use strict';
199
+ var boot = JSON.parse(document.getElementById('account-boot').textContent);
200
+ var P = homiePasskey;
201
+ var root = document.querySelector('[data-account]');
202
+ var msg = root.querySelector('[data-msg]');
203
+ var me = null;
204
+ function $(s) { return root.querySelector(s); }
205
+ function $$(s) { return Array.prototype.slice.call(root.querySelectorAll(s)); }
206
+ function say(text, kind) { msg.textContent = text || ''; msg.className = 'msg' + (kind ? ' ' + kind : ''); }
207
+ function view(name) { $$('[data-view]').forEach(function (el) { el.hidden = el.getAttribute('data-view') !== name; }); }
208
+ function busy(btn, on) { if (btn) { btn.disabled = on; btn.style.opacity = on ? '.6' : ''; } }
209
+ function done(r, words) {
210
+ say(words, 'good');
211
+ me = r.player; render();
212
+ var back = $('[data-back]');
213
+ if (back && boot.next && boot.next !== '/account/') setTimeout(function () { location.href = boot.next; }, 1400);
214
+ }
215
+ function fail(e) { say(e && e.name ? P.said(e) : (e && e.message) || 'That did not work.', 'bad'); }
216
+
217
+ function render() {
218
+ $$('[data-me-name]').forEach(function (el) { el.textContent = me ? me.name : ''; });
219
+ if (boot.mode && boot.link) return linkView();
220
+ if (!me) { view('out'); P.prime('up', '/api/player/signup/options'); P.prime('in', '/api/player/signin/options'); return; }
221
+ if (me.guest) { view('guest'); $('[data-gname]').value = me.named ? me.name : ''; P.prime('up', '/api/player/signup/options'); P.prime('in', '/api/player/signin/options'); return; }
222
+ view('in');
223
+ $('[data-rename]').value = me.name;
224
+ $('[data-owner-badge]').hidden = !me.owner;
225
+ $('[data-owner-claim]').hidden = !(boot.owner && !me.owner);
226
+ var ec = $('[data-email-card]');
227
+ if (ec) {
228
+ $('[data-email]').value = me.email ? me.email.address : '';
229
+ $('[data-email-state]').textContent = me.email ? (me.email.verified ? 'Confirmed: a link to this address can bring your account back.' : 'Waiting for you to open the confirmation link we sent.') : 'If you lose every passkey, a link to this address lets you make a new one. Optional.';
230
+ $('[data-email-remove]').hidden = !me.email;
231
+ }
232
+ keys();
233
+ }
234
+
235
+ function keys() {
236
+ P.api('GET', '/api/player/passkeys').then(function (r) {
237
+ var ul = $('[data-keys]'); ul.textContent = '';
238
+ (r.passkeys || []).forEach(function (k) {
239
+ var li = document.createElement('li');
240
+ var span = document.createElement('span');
241
+ span.textContent = k.label + (k.synced ? ' · synced' : '') + ' · last used ' + new Date(k.usedAt).toLocaleDateString();
242
+ li.appendChild(span);
243
+ if ((r.passkeys || []).length > 1) {
244
+ var b = document.createElement('button'); b.type = 'button'; b.className = 'ghost'; b.textContent = 'Remove';
245
+ b.addEventListener('click', function () {
246
+ if (!confirm('Remove this passkey? It will no longer sign you in here.')) return;
247
+ P.api('POST', '/api/player/passkeys/remove', { id: k.id }).then(function (x) { if (!x.ok) throw new Error(x.message); say('Passkey removed.', 'good'); keys(); }).catch(fail);
248
+ });
249
+ li.appendChild(b);
250
+ }
251
+ ul.appendChild(li);
252
+ });
253
+ });
254
+ }
255
+
256
+ function linkView() {
257
+ view('link');
258
+ var verify = boot.mode === 'verify';
259
+ $('[data-link-title]').textContent = verify ? 'Confirm your recovery email' : 'Get back into your account';
260
+ $('[data-link-text]').textContent = verify ? 'One press confirms this address for your account.' : 'Make a new passkey on this device. Your account, saves and stats stay as they are.';
261
+ var go = $('[data-link-go]');
262
+ go.textContent = verify ? 'Confirm' : 'Make a new passkey';
263
+ go.onclick = function () {
264
+ busy(go, true);
265
+ var k = boot.link; boot.link = null; boot.mode = null;
266
+ if (verify) {
267
+ P.api('POST', '/api/player/email/verify', { k: k }).then(function (r) { if (!r.ok) throw new Error(r.message); say('Confirmed. That address can now bring your account back.', 'good'); return load(); }).catch(fail);
268
+ return;
269
+ }
270
+ P.api('POST', '/api/player/recover/options', { k: k }).then(function (r) {
271
+ if (!r.ok) throw new Error(r.message);
272
+ return P.create(r.options);
273
+ }).then(function (cred) { return P.api('POST', '/api/player/signup', { credential: cred }); })
274
+ .then(function (r) { if (!r.ok) throw new Error(r.message); P.remember(r.player); done(r, 'You are back in. This device has its own passkey now.'); })
275
+ .catch(function (e) { fail(e); busy(go, false); });
276
+ };
277
+ }
278
+
279
+ function load() {
280
+ return P.api('GET', '/api/player/me').then(function (r) {
281
+ if (!r.ok) { view('out'); say(r.message || 'Accounts are not available here.', 'bad'); return; }
282
+ me = r.player; P.remember(me); render();
283
+ }).catch(function () { say('Could not reach the site. Check your connection.', 'bad'); });
284
+ }
285
+
286
+ if (!P.supported()) say('This browser has no passkeys. Use a current Safari, Chrome, Edge or Firefox.', 'bad');
287
+
288
+ $$('[data-make],[data-upgrade]').forEach(function (b) {
289
+ b.addEventListener('click', function () {
290
+ var input = b.hasAttribute('data-upgrade') ? $('[data-gname]') : $('[data-name]');
291
+ busy(b, true); say('Follow your device…');
292
+ P.signUp((input && input.value.trim()) || '').then(function (r) {
293
+ done(r, r.made === 'upgraded' ? 'Done: you have an account now, and everything you had is in it.' : 'Welcome, ' + r.player.name + '. Your account is ready.');
294
+ }).catch(fail).then(function () { busy(b, false); });
295
+ });
296
+ });
297
+ $$('[data-signin]').forEach(function (b) {
298
+ b.addEventListener('click', function () {
299
+ busy(b, true); say('Follow your device…');
300
+ P.signIn().then(function (r) { done(r, 'Signed in as ' + r.player.name + '.'); }).catch(fail).then(function () { busy(b, false); });
301
+ });
302
+ });
303
+ $('[data-add]').addEventListener('click', function () {
304
+ var b = $('[data-add]'); busy(b, true); say('Follow your device…');
305
+ P.signUp('').then(function (r) { done(r, 'This device has a passkey for your account now.'); }).catch(fail).then(function () { busy(b, false); });
306
+ });
307
+ $('[data-save-name]').addEventListener('click', function () {
308
+ P.api('POST', '/api/player/name', { name: $('[data-rename]').value }).then(function (r) {
309
+ if (!r.ok) throw new Error(r.message);
310
+ me = r.player; P.remember(me); render(); say(r.cut ? 'Saved (cut to 24 characters).' : 'Saved.', 'good');
311
+ }).catch(fail);
312
+ });
313
+ $('[data-signout]').addEventListener('click', function () {
314
+ P.api('POST', '/api/player/signout', {}).then(function () { me = null; P.remember(null); render(); say('Signed out on this browser.', 'good'); });
315
+ });
316
+ $('[data-delete]').addEventListener('click', function () {
317
+ var typed = prompt('This deletes your account and everything it kept on ' + boot.studio + ', in every game. It cannot be undone. Type DELETE to go ahead.');
318
+ if (!typed || typed.trim().toUpperCase() !== 'DELETE') { say('Nothing was deleted.'); return; }
319
+ P.api('POST', '/api/player/delete', { confirm: 'delete' }).then(function (r) {
320
+ if (!r.ok) throw new Error(r.message);
321
+ me = null; P.remember(null); render();
322
+ say('Your account and everything it kept are deleted. You can also remove the passkey from your device\\'s passwords.', 'good');
323
+ }).catch(fail);
324
+ });
325
+ var claim = $('[data-claim]');
326
+ if (claim) claim.addEventListener('click', function () {
327
+ P.api('POST', '/api/player/owner', { k: boot.owner }).then(function (r) {
328
+ if (!r.ok) throw new Error(r.message);
329
+ boot.owner = null; me = r.player; render(); say('This account is the studio owner\\'s now.', 'good');
330
+ }).catch(fail);
331
+ });
332
+ var es = $('[data-email-save]');
333
+ if (es) es.addEventListener('click', function () {
334
+ P.api('POST', '/api/player/email', { email: $('[data-email]').value }).then(function (r) {
335
+ if (!r.ok) throw new Error(r.message);
336
+ me = r.player; render(); say('Sent. Open the link in that email to confirm it.', 'good');
337
+ }).catch(fail);
338
+ });
339
+ var er = $('[data-email-remove]');
340
+ if (er) er.addEventListener('click', function () {
341
+ P.api('POST', '/api/player/email/remove', {}).then(function (r) { if (!r.ok) throw new Error(r.message); me = r.player; render(); say('Removed.', 'good'); }).catch(fail);
342
+ });
343
+ var sr = $('[data-show-recover]');
344
+ if (sr) sr.addEventListener('click', function (e) { e.preventDefault(); $('[data-recover]').hidden = false; $('[data-recover-email]').focus(); });
345
+ var rs = $('[data-recover-send]');
346
+ if (rs) rs.addEventListener('click', function () {
347
+ P.api('POST', '/api/player/recover', { email: $('[data-recover-email]').value }).then(function (r) { if (!r.ok) throw new Error(r.message); say(r.message, 'good'); }).catch(fail);
348
+ });
349
+ load();
350
+ }());`;
package/worker/index.mjs CHANGED
@@ -29,6 +29,9 @@
29
29
  * /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
30
30
  * /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
31
31
  * /_homie/site.js the pages' one script
32
+ * /account/ a player's account: a passkey, a name, their data (worker/players.mjs, saves/SAVES.md)
33
+ * /api/player/... sign in and up, saves, lifetime stats and memorials (the game's saves bridge, via the
34
+ * play shell), export and delete
32
35
  *
33
36
  * A page in the studio's site/pages wins over the generated one at the same address. Every HTML answer is
34
37
  * `no-transform` (an edge in front of a custom domain injects nothing) and is never framed by another site.
@@ -57,11 +60,14 @@ import {
57
60
  } from './site.mjs';
58
61
  import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
59
62
  import { ownerRoutes } from './stats-page.mjs';
63
+ import { playerRoutes } from './players.mjs';
60
64
  import { STUDIO_VERSION_TAG } from './version.mjs';
61
65
 
62
66
  export { SEAT_MAX } from './seats.mjs';
63
67
  /** For a studio's own wrapper Worker: count its own pages the way the template counts its pages. */
64
68
  export { countVisit } from './stats.mjs';
69
+ /** Players and cloud saves (saves/SAVES.md): `players.list`, `players.get`, `players.isOwner`… for the back office. */
70
+ export { players, cleanName } from './players.mjs';
65
71
 
66
72
  const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
67
73
  const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
@@ -109,11 +115,23 @@ const claimMissed = new Map();
109
115
  async function storedClaim(env, origin) {
110
116
  if (!env.DB) return null;
111
117
  try {
112
- const rows = (await env.DB.prepare('SELECT key, value FROM meta WHERE key IN (?1, ?2)').bind(`homie_claim:${origin}`, 'homie_claim').all()).results ?? [];
113
- return { own: rows.find((r) => r.key === `homie_claim:${origin}`)?.value ?? null, older: rows.find((r) => r.key === 'homie_claim')?.value ?? null };
118
+ const rows = (await env.DB.prepare('SELECT key, value FROM meta WHERE key IN (?1, ?2, ?3)').bind(`homie_claim:${origin}`, 'homie_claim', `homie_repo:${origin}`).all()).results ?? [];
119
+ const of = (k) => rows.find((r) => r.key === k)?.value ?? null;
120
+ return { own: of(`homie_claim:${origin}`), older: of('homie_claim'), repo: of(`homie_repo:${origin}`) };
114
121
  } catch { return null; }
115
122
  }
116
123
 
124
+ /*
125
+ * THE STUDIO'S GITHUB REPOSITORY (HOMIE_REPO, set by `homie-studio deploy` from the studio's own git remote or
126
+ * studio.json "github"; lib/repo.mjs). The site tells its directory which repository it is built from, with its
127
+ * claim, so the Claude app's hand-off opens Claude Code on the studio's own repository. It is never put in a public
128
+ * page: only the directory hears it, from this Worker (Cloudflare's CF-Worker header names the zone it runs on),
129
+ * and it is said again only when it changes.
130
+ */
131
+ const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
132
+ const repoOf = (env) => { const r = String(env.HOMIE_REPO ?? '').trim(); return REPO.test(r) && r.toLowerCase() !== 'homie-rocks/homie' ? r : null; };
133
+ const repoSaid = new Map();
134
+
117
135
  export function directoryOf(cat) {
118
136
  const d = cat?.studio?.directory;
119
137
  if (d === null || d === false) return null;
@@ -127,28 +145,34 @@ export function directoryOf(cat) {
127
145
  /** This address's claim: kept, else asked of the directory now (and kept), else an older studio's single claim. */
128
146
  async function claimOf(env, cat, url) {
129
147
  const origin = url.origin;
130
- if (claimed.has(origin)) return claimed.get(origin);
148
+ const repo = repoOf(env);
149
+ if (claimed.has(origin) && (!repo || repoSaid.get(origin) === repo)) return claimed.get(origin);
131
150
  const kept = await storedClaim(env, origin);
132
- if (kept?.own) { claimed.set(origin, kept.own); return kept.own; }
151
+ if (kept?.own && (!repo || kept.repo === repo)) { claimed.set(origin, kept.own); if (repo) repoSaid.set(origin, repo); return kept.own; }
133
152
  const directory = directoryOf(cat);
134
153
  const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
135
154
  const may = directory && env.DB && env.HOMIE_PREVIEW !== '1' && (url.protocol === 'https:' || (local && directory.startsWith('http://')))
136
155
  && Date.now() - (claimMissed.get(origin) ?? 0) > 60_000;
137
156
  if (may) {
138
157
  try {
139
- const res = await fetch(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}`, {
158
+ // Asked when there is no claim yet, or to say a repository the directory has not heard from this site.
159
+ const res = await fetch(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}${repo ? `&repo=${encodeURIComponent(repo)}` : ''}`, {
140
160
  headers: { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION_TAG} (site claim)` }, signal: AbortSignal.timeout(5000),
141
161
  });
142
162
  const body = await res.json().catch(() => null);
143
163
  if (res.ok && CLAIM.test(String(body?.claim ?? ''))) {
144
- await env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_claim:${origin}`, body.claim).run();
164
+ await env.DB.batch([
165
+ env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_claim:${origin}`, body.claim),
166
+ ...(repo ? [env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_repo:${origin}`, repo)] : []),
167
+ ]);
145
168
  claimed.set(origin, body.claim);
169
+ if (repo) repoSaid.set(origin, repo);
146
170
  return body.claim;
147
171
  }
148
172
  } catch { /* the directory is optional for a live site */ }
149
173
  claimMissed.set(origin, Date.now());
150
174
  }
151
- return kept?.older ?? null;
175
+ return kept?.own ?? kept?.older ?? null;
152
176
  }
153
177
 
154
178
  /** A copy of the public template nobody has named yet shows the name typed into Cloudflare's form (STUDIO_NAME). */
@@ -277,6 +301,8 @@ async function gameDocument(request, env, url, game, meta, cat) {
277
301
  want,
278
302
  debug: url.searchParams.get('debug') === '1',
279
303
  ...(meta?.movement ? { movement: meta.movement } : {}),
304
+ // game.json "saves": the play shell around this frame answers @homie-rocks/studio/saves (saves/SAVES.md).
305
+ ...(meta?.saves && want === 'play' ? { saves: true } : {}),
280
306
  };
281
307
  let html = await res.text();
282
308
  const head = `<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
@@ -375,6 +401,7 @@ async function route(request, env, ctx) {
375
401
  }
376
402
  if (path.startsWith('/_studio/')) return ownerRoutes(request, env, url, { catalogueOf: getCat });
377
403
  if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
404
+ if (path.startsWith('/api/player/') || path === '/account' || path === '/account/' || path === '/_homie/account.js') return playerRoutes(request, env, ctx, url, { catalogueOf: getCat });
378
405
  if (path === '/_homie/site.js') return new Response(SITE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
379
406
  if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
380
407
  if (path === '/api/stats') {
@@ -441,7 +468,8 @@ async function route(request, env, ctx) {
441
468
  if (path.startsWith('/media/')) {
442
469
  if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
443
470
  const key = decodeURIComponent(path.slice('/media/'.length));
444
- if (!key || key.includes('..')) return new Response('not found', { status: 404 });
471
+ // A player's large saves live under players/ in the same bucket: never served here (saves/SAVES.md).
472
+ if (!key || key.includes('..') || key.startsWith('players/')) return new Response('not found', { status: 404 });
445
473
  return mediaObject(request, env, key);
446
474
  }
447
475
  if (path === '/posts/feed.xml' || path === '/posts/feed.json') {
package/worker/pages.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  * worker/site.mjs. Every name a player typed is escaped.
4
4
  */
5
5
  import { esc, layout } from './site.mjs';
6
+ import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
6
7
 
7
8
  export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
8
9
 
@@ -121,7 +122,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
121
122
  .sheet .foot a, .sheet .foot button { color: #aab3c7; background: none; border: 0; padding: 6px 2px; font: inherit; text-decoration: none; cursor: pointer; }
122
123
  .sheet .foot a:hover, .sheet .foot button:hover { color: #fff; }
123
124
  .toast { position: fixed; left: 50%; top: max(12px, env(safe-area-inset-top)); z-index: 8; transform: translateX(-50%); padding: 8px 14px; border-radius: 999px; background: rgba(8,12,22,.9); color: #fff; font: 600 13px/1.2 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
124
- [hidden] { display: none !important; }`;
125
+ [hidden] { display: none !important; }${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
125
126
  // The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
126
127
  const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(room ? { room } : {}) };
127
128
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
@@ -137,7 +138,8 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
137
138
  <a data-bigscreen target="_blank" rel="noopener" href="/${esc(g.id)}/tv"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="4" width="19" height="13" rx="2"/><path d="M8 20.5h8"/></svg><span>Big screen</span></a>
138
139
  </div>
139
140
  <span class="link" data-room-link></span>
140
- <div class="foot"><a href="/${esc(g.id)}/">← ${esc(g.name)}</a><button type="button" data-copy-link>Copy link</button></div>
141
+ <div class="foot"><a href="/${esc(g.id)}/">← ${esc(g.name)}</a><button type="button" data-copy-link>Copy link</button></div>${g.saves ? `
142
+ <div class="who-row" data-who hidden><span data-who-name></span><button type="button" data-who-act></button></div>` : ''}
141
143
  </div>
142
144
  </div>
143
145
  <div class="toast" data-toast role="status" hidden></div>`;
@@ -148,7 +150,8 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
148
150
  <div class="card" data-screen hidden></div>
149
151
  ${joinCard}${share}
150
152
  <script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
151
- <script>${SHELL_JS}</script>`, css, frameAncestors(cat));
153
+ <script>${SHELL_JS}</script>${g.saves && !screen ? `
154
+ <script>${SAVES_SHELL_JS}</script>` : ''}`, css, frameAncestors(cat));
152
155
  }
153
156
 
154
157
  /** The play page's own document (no site chrome: the game owns the whole screen). */
@@ -263,6 +266,10 @@ const SHELL_JS = String.raw`(function () {
263
266
  var q = new URLSearchParams({ room: room, device: device, want: want });
264
267
  if (token) q.set('k', token);
265
268
  if (params.get('name')) q.set('name', params.get('name'));
269
+ else {
270
+ // A player with an account (or a named guest) on this studio plays under their own name in every room.
271
+ try { var who = JSON.parse(localStorage.getItem('homie.player') || 'null'); if (who && typeof who.name === 'string' && who.name) q.set('name', who.name.slice(0, 24)); } catch (e) {}
272
+ }
266
273
  if (params.get('debug') === '1') q.set('debug', '1');
267
274
  // The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
268
275
  ['touchdebug', 'cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });