@homie-rocks/studio 0.11.0 → 0.12.0
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/README.md +33 -6
- package/bin/homie-studio.mjs +17 -4
- package/lib/build.mjs +3 -0
- package/lib/cloudflare.mjs +4 -5
- package/lib/codex.mjs +1 -0
- package/lib/players.mjs +38 -0
- package/lib/progress.mjs +4 -3
- package/lib/scaffold.mjs +31 -1
- package/lib/site.mjs +2 -2
- package/lib/stats.mjs +3 -3
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +24 -0
- package/netplay/NETPLAY.md +5 -0
- package/package.json +6 -1
- package/port/index.ts +2 -0
- package/port/keys.ts +3 -0
- package/saves/SAVES.md +236 -0
- package/saves/saves.ts +326 -0
- package/site/SITE.md +9 -0
- package/starters/ember-vale/game.json +20 -0
- package/starters/ember-vale/index.html +46 -0
- package/starters/ember-vale/src/main.ts +429 -0
- package/worker/account-page.mjs +350 -0
- package/worker/index.mjs +11 -1
- package/worker/pages.mjs +10 -3
- package/worker/players.mjs +817 -0
- package/worker/saves-shell.mjs +388 -0
- package/worker/saves.mjs +287 -0
- package/worker/site.mjs +1 -1
- package/worker/stats-page.mjs +1 -0
- package/worker/stats.mjs +16 -1
- package/worker/version.mjs +1 -1
- package/worker/webauthn.mjs +259 -0
|
@@ -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}$/;
|
|
@@ -277,6 +283,8 @@ async function gameDocument(request, env, url, game, meta, cat) {
|
|
|
277
283
|
want,
|
|
278
284
|
debug: url.searchParams.get('debug') === '1',
|
|
279
285
|
...(meta?.movement ? { movement: meta.movement } : {}),
|
|
286
|
+
// game.json "saves": the play shell around this frame answers @homie-rocks/studio/saves (saves/SAVES.md).
|
|
287
|
+
...(meta?.saves && want === 'play' ? { saves: true } : {}),
|
|
280
288
|
};
|
|
281
289
|
let html = await res.text();
|
|
282
290
|
const head = `<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
@@ -375,6 +383,7 @@ async function route(request, env, ctx) {
|
|
|
375
383
|
}
|
|
376
384
|
if (path.startsWith('/_studio/')) return ownerRoutes(request, env, url, { catalogueOf: getCat });
|
|
377
385
|
if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
|
|
386
|
+
if (path.startsWith('/api/player/') || path === '/account' || path === '/account/' || path === '/_homie/account.js') return playerRoutes(request, env, ctx, url, { catalogueOf: getCat });
|
|
378
387
|
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
388
|
if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
|
|
380
389
|
if (path === '/api/stats') {
|
|
@@ -441,7 +450,8 @@ async function route(request, env, ctx) {
|
|
|
441
450
|
if (path.startsWith('/media/')) {
|
|
442
451
|
if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
|
|
443
452
|
const key = decodeURIComponent(path.slice('/media/'.length));
|
|
444
|
-
|
|
453
|
+
// A player's large saves live under players/ in the same bucket: never served here (saves/SAVES.md).
|
|
454
|
+
if (!key || key.includes('..') || key.startsWith('players/')) return new Response('not found', { status: 404 });
|
|
445
455
|
return mediaObject(request, env, key);
|
|
446
456
|
}
|
|
447
457
|
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
|
|
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); });
|