@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,817 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PLAYERS — accounts on ONE studio, passkey first, and the cloud saves a persistent game keeps (saves/SAVES.md).
|
|
3
|
+
*
|
|
4
|
+
* A player is a row in the studio's own D1 and nothing else: a random id (`pl_…`, names nobody), a display name
|
|
5
|
+
* (a two-word handle until they choose one), and the passkeys they made here. No password, no email address
|
|
6
|
+
* required, no cookie from anyone else, nothing sent anywhere. Another studio cannot see this one's players: the
|
|
7
|
+
* identity is this site's alone (a passkey is bound to this site's host name, and the rows are in this D1).
|
|
8
|
+
*
|
|
9
|
+
* guest Pressing Play needs nothing. The first time a game SAVES something, this site makes a guest player
|
|
10
|
+
* and keeps it in a cookie (HttpOnly, this site only). A guest keeps their progress on that browser.
|
|
11
|
+
* account The guest (or anyone) makes a passkey: Face ID, a fingerprint, a PIN or a security key. The guest
|
|
12
|
+
* becomes an account and keeps every save. On another device, "Sign in" with the same passkey (the
|
|
13
|
+
* device's own sync carries it: iCloud Keychain, Google Password Manager, a password manager, or a
|
|
14
|
+
* phone's QR code) and the same saves are there. A second passkey (another device, a security key) can
|
|
15
|
+
* be added any time; a recovery email only when the studio has a mail sender (PLAYER_MAIL).
|
|
16
|
+
* owner The studio's owner marks their own account once (`homie-studio players owner`): the back office and
|
|
17
|
+
* the game can then recognise them (`players.isOwner(request, env)`).
|
|
18
|
+
*
|
|
19
|
+
* WHAT IS STORED (migration 0004_players.sql): players (id, display name, guest/owner flags, created and last-seen
|
|
20
|
+
* times), each passkey's public key and counter (never a private key: it cannot leave the device), sessions and
|
|
21
|
+
* one-time challenges as SHA-256 hashes only, saves, lifetime stats and memorials, and, only if the player adds
|
|
22
|
+
* one, a recovery email. No IP address is stored: rate limits count in memory and forget.
|
|
23
|
+
*
|
|
24
|
+
* THE OWNER sees counts and names (`players.count`, `players.list`), never a passkey, a session or an email.
|
|
25
|
+
* A player sees, downloads (/api/player/export) and deletes (/api/player/delete) everything of theirs.
|
|
26
|
+
*/
|
|
27
|
+
import { handleFor } from './room.mjs';
|
|
28
|
+
import { cookieValue } from './stats.mjs';
|
|
29
|
+
import { adoptStatements, bumpPlayerStats, dropPlayerData, fall, fallenList, getSave, listSaves, playerData, readPlayerStats, SAVE_LIMITS, savesSummary, wipeSaves, writeSaves } from './saves.mjs';
|
|
30
|
+
import { bytesToB64url, randomToken, sha256Hex, verifyAssertion, verifyRegistration } from './webauthn.mjs';
|
|
31
|
+
import { ACCOUNT_JS, accountPage } from './account-page.mjs';
|
|
32
|
+
|
|
33
|
+
export const PLAYERS_MIGRATION_FILE = '0004_players.sql';
|
|
34
|
+
export const PLAYERS_MIGRATION = `-- Players and cloud saves (@homie-rocks/studio 0.12.0): accounts on this studio only (saves/SAVES.md).
|
|
35
|
+
-- A player is a random id and a display name. Passkeys keep only their PUBLIC key; sessions and one-time
|
|
36
|
+
-- challenges keep only a SHA-256 hash. No IP address, no password, no email unless a player adds one.
|
|
37
|
+
CREATE TABLE IF NOT EXISTS players (
|
|
38
|
+
id TEXT PRIMARY KEY,
|
|
39
|
+
name TEXT NOT NULL,
|
|
40
|
+
named INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
guest INTEGER NOT NULL DEFAULT 1,
|
|
42
|
+
owner INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
created_at INTEGER NOT NULL,
|
|
44
|
+
seen_at INTEGER NOT NULL
|
|
45
|
+
);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS players_created ON players (created_at);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS players_seen ON players (guest, seen_at);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS player_passkeys (
|
|
49
|
+
id TEXT PRIMARY KEY,
|
|
50
|
+
player TEXT NOT NULL,
|
|
51
|
+
public_key TEXT NOT NULL,
|
|
52
|
+
alg INTEGER NOT NULL,
|
|
53
|
+
sign_count INTEGER NOT NULL DEFAULT 0,
|
|
54
|
+
label TEXT NOT NULL DEFAULT '',
|
|
55
|
+
synced INTEGER NOT NULL DEFAULT 0,
|
|
56
|
+
created_at INTEGER NOT NULL,
|
|
57
|
+
used_at INTEGER NOT NULL
|
|
58
|
+
);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS player_passkeys_player ON player_passkeys (player);
|
|
60
|
+
CREATE TABLE IF NOT EXISTS player_sessions (
|
|
61
|
+
hash TEXT PRIMARY KEY,
|
|
62
|
+
player TEXT NOT NULL,
|
|
63
|
+
kind TEXT NOT NULL DEFAULT 'session',
|
|
64
|
+
game TEXT,
|
|
65
|
+
expires_at INTEGER NOT NULL
|
|
66
|
+
) WITHOUT ROWID;
|
|
67
|
+
CREATE INDEX IF NOT EXISTS player_sessions_player ON player_sessions (player);
|
|
68
|
+
CREATE TABLE IF NOT EXISTS player_challenges (
|
|
69
|
+
hash TEXT PRIMARY KEY,
|
|
70
|
+
purpose TEXT NOT NULL,
|
|
71
|
+
player TEXT,
|
|
72
|
+
data TEXT,
|
|
73
|
+
expires_at INTEGER NOT NULL
|
|
74
|
+
) WITHOUT ROWID;
|
|
75
|
+
CREATE TABLE IF NOT EXISTS player_emails (
|
|
76
|
+
player TEXT PRIMARY KEY,
|
|
77
|
+
email TEXT NOT NULL,
|
|
78
|
+
verified_at INTEGER,
|
|
79
|
+
added_at INTEGER NOT NULL
|
|
80
|
+
);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS player_emails_email ON player_emails (email);
|
|
82
|
+
CREATE TABLE IF NOT EXISTS saves (
|
|
83
|
+
player TEXT NOT NULL,
|
|
84
|
+
game TEXT NOT NULL,
|
|
85
|
+
key TEXT NOT NULL,
|
|
86
|
+
value TEXT NOT NULL,
|
|
87
|
+
bytes INTEGER NOT NULL,
|
|
88
|
+
version INTEGER NOT NULL,
|
|
89
|
+
updated_at INTEGER NOT NULL,
|
|
90
|
+
blob INTEGER NOT NULL DEFAULT 0,
|
|
91
|
+
PRIMARY KEY (player, game, key)
|
|
92
|
+
) WITHOUT ROWID;
|
|
93
|
+
CREATE TABLE IF NOT EXISTS player_stats (
|
|
94
|
+
player TEXT NOT NULL,
|
|
95
|
+
game TEXT NOT NULL,
|
|
96
|
+
name TEXT NOT NULL,
|
|
97
|
+
n REAL NOT NULL,
|
|
98
|
+
updated_at INTEGER NOT NULL,
|
|
99
|
+
PRIMARY KEY (player, game, name)
|
|
100
|
+
) WITHOUT ROWID;
|
|
101
|
+
CREATE TABLE IF NOT EXISTS memorials (
|
|
102
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
103
|
+
player TEXT NOT NULL,
|
|
104
|
+
game TEXT NOT NULL,
|
|
105
|
+
character TEXT NOT NULL,
|
|
106
|
+
player_name TEXT NOT NULL,
|
|
107
|
+
summary TEXT NOT NULL,
|
|
108
|
+
at INTEGER NOT NULL
|
|
109
|
+
);
|
|
110
|
+
CREATE INDEX IF NOT EXISTS memorials_game_at ON memorials (game, at);
|
|
111
|
+
CREATE INDEX IF NOT EXISTS memorials_player ON memorials (player);
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
export const PLAYER_COOKIE = 'studio_player';
|
|
115
|
+
const MINUTE = 60_000;
|
|
116
|
+
const HOUR = 60 * MINUTE;
|
|
117
|
+
const DAY = 24 * HOUR;
|
|
118
|
+
/** How long things live, and the rate and size caps (per Worker instance for rates; in D1 for storage). */
|
|
119
|
+
export const PLAYER_LIMITS = Object.freeze({
|
|
120
|
+
sessionDays: 120, guestSessionDays: 365, guestIdleDays: 180, challengeMinutes: 5, passMinutes: 10, linkMinutes: 30,
|
|
121
|
+
passkeys: 10, playersPerDay: 5000, bodyBytes: 1_200_000,
|
|
122
|
+
// Per address (in memory, never stored): ceremonies and new players. A party on one Wi-Fi shares an address.
|
|
123
|
+
ceremoniesPerTenMinutes: 60, newPlayersPerHour: 60, mailsPerHour: 6,
|
|
124
|
+
// Per player (in memory): writes and reads a minute.
|
|
125
|
+
writesPerMinute: 120, readsPerMinute: 240,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const PLAYER_ID = /^pl_[A-Za-z0-9_-]{22}$/;
|
|
129
|
+
const TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
130
|
+
const PASS = /^pp_[A-Za-z0-9_-]{32}$/;
|
|
131
|
+
const LINK = /^[A-Za-z0-9_-]{43}$/;
|
|
132
|
+
const CRED_ID = /^[A-Za-z0-9_-]{16,1400}$/;
|
|
133
|
+
const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
134
|
+
const OWNER_KEY = /^hsk_[a-f0-9]{48}$/;
|
|
135
|
+
const EMAIL = /^[^\s@<>()",;:]{1,64}@[A-Za-z0-9.-]{1,190}\.[A-Za-z]{2,24}$/;
|
|
136
|
+
|
|
137
|
+
const json = (body, status = 200, headers = {}) => new Response(`${JSON.stringify(body)}\n`, {
|
|
138
|
+
status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store, private', 'x-content-type-options': 'nosniff', ...headers },
|
|
139
|
+
});
|
|
140
|
+
const fail = (status, error, message, extra = {}) => json({ ok: false, error, message, ...extra }, status);
|
|
141
|
+
|
|
142
|
+
/* ------------------------------------------------------------------ names */
|
|
143
|
+
|
|
144
|
+
/** Words a name may not open with unless the player is the studio's owner: a name never claims a role. */
|
|
145
|
+
const ROLE_WORDS = /^(?:the\s+)?(?:admin|administrator|mod|moderator|owner|staff|system|official|support|dev|developer|gm|game\s*master|homie)(?:\b|$)/i;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A display name, by the same rules as a room's typed names and handles: one line of at most 24 characters,
|
|
149
|
+
* whitespace collapsed, no control, format or invisible characters (no zero-width or bidi tricks), at least one
|
|
150
|
+
* letter or digit; never a role (admin, moderator, owner…) or the studio's own name, except for the owner. It is
|
|
151
|
+
* always shown as text (escaped), never as markup. `max` lets a character's name run to 32.
|
|
152
|
+
*/
|
|
153
|
+
export function cleanName(raw, { studio = '', owner = false, max = 24 } = {}) {
|
|
154
|
+
const text = String(raw ?? '').normalize('NFKC')
|
|
155
|
+
.replace(/[\p{Cc}\p{Cf}\p{Co}\p{Cs}\u2028\u2029\u115F\u1160\u3164\uFFA0]/gu, '')
|
|
156
|
+
.replace(/\s+/gu, ' ').trim();
|
|
157
|
+
const chars = [...text];
|
|
158
|
+
const name = chars.slice(0, max).join('').trim();
|
|
159
|
+
if (!name) return { ok: false, error: 'name', message: 'a name needs at least one letter or digit' };
|
|
160
|
+
if (!/[\p{L}\p{N}]/u.test(name)) return { ok: false, error: 'name', message: 'a name needs at least one letter or digit' };
|
|
161
|
+
if (!owner && (ROLE_WORDS.test(name) || (studio && name.toLowerCase() === String(studio).trim().toLowerCase()))) {
|
|
162
|
+
return { ok: false, error: 'name-taken', message: 'that name belongs to the studio; pick another' };
|
|
163
|
+
}
|
|
164
|
+
return { ok: true, name, cut: chars.length > max };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ------------------------------------------------------------------ rate limits (memory only) */
|
|
168
|
+
|
|
169
|
+
const buckets = new Map();
|
|
170
|
+
/** True when `key` has had `max` hits in the last `windowMs` (and this one is refused); otherwise counts it. */
|
|
171
|
+
export function limited(key, max, windowMs, now = Date.now()) {
|
|
172
|
+
let list = buckets.get(key);
|
|
173
|
+
if (!list) { list = []; buckets.set(key, list); }
|
|
174
|
+
while (list.length && list[0] <= now - windowMs) list.shift();
|
|
175
|
+
if (list.length >= max) return true;
|
|
176
|
+
list.push(now);
|
|
177
|
+
if (buckets.size > 20_000) {
|
|
178
|
+
for (const [k, l] of buckets) if (!l.length || l[l.length - 1] < now - HOUR) buckets.delete(k);
|
|
179
|
+
if (buckets.size > 20_000) buckets.clear();
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
/** For tests: forget every count. */
|
|
184
|
+
export function resetLimits() { buckets.clear(); }
|
|
185
|
+
const addressOf = (request) => request.headers.get('cf-connecting-ip') || 'local';
|
|
186
|
+
|
|
187
|
+
/* ------------------------------------------------------------------ cookies, sessions */
|
|
188
|
+
|
|
189
|
+
function sessionCookie(url, token, days) {
|
|
190
|
+
return `${PLAYER_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.round(days * 86400)}${url.protocol === 'https:' ? '; Secure' : ''}`;
|
|
191
|
+
}
|
|
192
|
+
const clearCookie = (url) => `${PLAYER_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${url.protocol === 'https:' ? '; Secure' : ''}`;
|
|
193
|
+
|
|
194
|
+
const newPlayerId = () => `pl_${randomToken(16).slice(0, 22)}`;
|
|
195
|
+
|
|
196
|
+
/** A new session for `player`: its D1 statement and its cookie. */
|
|
197
|
+
async function mintSession(env, url, player, guest) {
|
|
198
|
+
const token = randomToken(32);
|
|
199
|
+
const days = guest ? PLAYER_LIMITS.guestSessionDays : PLAYER_LIMITS.sessionDays;
|
|
200
|
+
return {
|
|
201
|
+
statement: env.DB.prepare("INSERT INTO player_sessions (hash, player, kind, expires_at) VALUES (?1, ?2, 'session', ?3)").bind(await sha256Hex(token), player, Date.now() + days * DAY),
|
|
202
|
+
cookie: sessionCookie(url, token, days),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The signed-in player of this request (its session cookie), or null. `touch` keeps last-seen and the session fresh. */
|
|
207
|
+
async function sessionOf(request, env, url = new URL(request.url)) {
|
|
208
|
+
const token = cookieValue(request, PLAYER_COOKIE);
|
|
209
|
+
if (!token || !TOKEN.test(token) || !env?.DB) return null;
|
|
210
|
+
const hash = await sha256Hex(token);
|
|
211
|
+
const row = await env.DB.prepare("SELECT s.player, s.expires_at, p.name, p.named, p.guest, p.owner, p.created_at, p.seen_at FROM player_sessions s JOIN players p ON p.id = s.player WHERE s.hash = ?1 AND s.kind = 'session'").bind(hash).first();
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
if (!row || Number(row.expires_at) <= now) return null;
|
|
214
|
+
const guest = Number(row.guest) === 1;
|
|
215
|
+
const session = {
|
|
216
|
+
hash, token,
|
|
217
|
+
player: { id: row.player, name: row.name, named: Number(row.named) === 1, guest, owner: Number(row.owner) === 1, since: Number(row.created_at) },
|
|
218
|
+
cookies: [],
|
|
219
|
+
};
|
|
220
|
+
const writes = [];
|
|
221
|
+
if (now - Number(row.seen_at) > 6 * HOUR) writes.push(env.DB.prepare('UPDATE players SET seen_at = ?2 WHERE id = ?1').bind(row.player, now));
|
|
222
|
+
const days = guest ? PLAYER_LIMITS.guestSessionDays : PLAYER_LIMITS.sessionDays;
|
|
223
|
+
if (Number(row.expires_at) - now < (days / 2) * DAY) {
|
|
224
|
+
writes.push(env.DB.prepare('UPDATE player_sessions SET expires_at = ?2 WHERE hash = ?1').bind(hash, now + days * DAY));
|
|
225
|
+
session.cookies.push(sessionCookie(url, token, days));
|
|
226
|
+
}
|
|
227
|
+
if (writes.length) await env.DB.batch(writes).catch(() => {});
|
|
228
|
+
return session;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** A guest for a request with no player yet (the first save). Refused past the address's or the day's limit. */
|
|
232
|
+
async function newGuest(request, env, url) {
|
|
233
|
+
const ip = addressOf(request);
|
|
234
|
+
if (limited(`new:${ip}`, PLAYER_LIMITS.newPlayersPerHour, HOUR)) return { error: fail(429, 'rate', 'too many new players from this address; try again later') };
|
|
235
|
+
const capped = await dailyCapReached(env);
|
|
236
|
+
if (capped) return { error: fail(503, 'busy', 'this studio has taken all the new players it takes in one day; try again tomorrow') };
|
|
237
|
+
const id = newPlayerId();
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
const name = handleFor(id);
|
|
240
|
+
const s = await mintSession(env, url, id, true);
|
|
241
|
+
await env.DB.batch([
|
|
242
|
+
env.DB.prepare('INSERT INTO players (id, name, named, guest, owner, created_at, seen_at) VALUES (?1, ?2, 0, 1, 0, ?3, ?3)').bind(id, name, now),
|
|
243
|
+
s.statement,
|
|
244
|
+
]);
|
|
245
|
+
if (Math.random() < 0.02) pruneGuests(env).catch(() => {});
|
|
246
|
+
return { session: { player: { id, name, named: false, guest: true, owner: false, since: now }, cookies: [s.cookie], fresh: true } };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function dailyCapReached(env) {
|
|
250
|
+
const cap = Math.max(1, Math.floor(Number(env.PLAYER_LIMIT_DAILY)) || PLAYER_LIMITS.playersPerDay);
|
|
251
|
+
const row = await env.DB.prepare('SELECT COUNT(*) AS n FROM players WHERE created_at >= ?1').bind(Date.now() - DAY).first();
|
|
252
|
+
return Number(row?.n) >= cap;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Guests nobody has used for guestIdleDays are forgotten (a few at a time), with everything they kept. */
|
|
256
|
+
async function pruneGuests(env) {
|
|
257
|
+
const old = (await env.DB.prepare('SELECT id FROM players WHERE guest = 1 AND seen_at < ?1 LIMIT 25').bind(Date.now() - PLAYER_LIMITS.guestIdleDays * DAY).all()).results ?? [];
|
|
258
|
+
for (const { id } of old) await removePlayer(env, id);
|
|
259
|
+
await env.DB.prepare('DELETE FROM player_challenges WHERE expires_at < ?1').bind(Date.now()).run();
|
|
260
|
+
await env.DB.prepare('DELETE FROM player_sessions WHERE expires_at < ?1').bind(Date.now()).run();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* ------------------------------------------------------------------ challenges */
|
|
264
|
+
|
|
265
|
+
async function issueChallenge(env, purpose, player = null, data = null, minutes = PLAYER_LIMITS.challengeMinutes) {
|
|
266
|
+
const challenge = randomToken(32);
|
|
267
|
+
const writes = [env.DB.prepare('INSERT INTO player_challenges (hash, purpose, player, data, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)')
|
|
268
|
+
.bind(await sha256Hex(challenge), purpose, player, data === null ? null : JSON.stringify(data), Date.now() + minutes * MINUTE)];
|
|
269
|
+
if (Math.random() < 0.05) writes.push(env.DB.prepare('DELETE FROM player_challenges WHERE expires_at < ?1').bind(Date.now()));
|
|
270
|
+
await env.DB.batch(writes);
|
|
271
|
+
return challenge;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Spend a challenge (once): its purpose, player and data, or null when unknown, spent or expired. */
|
|
275
|
+
async function spendChallenge(env, challenge, purposes) {
|
|
276
|
+
if (typeof challenge !== 'string' || !/^[A-Za-z0-9_-]{32,64}$/.test(challenge)) return null;
|
|
277
|
+
const row = await env.DB.prepare('DELETE FROM player_challenges WHERE hash = ?1 RETURNING purpose, player, data, expires_at').bind(await sha256Hex(challenge)).first();
|
|
278
|
+
if (!row || Number(row.expires_at) <= Date.now() || !purposes.includes(row.purpose)) return null;
|
|
279
|
+
let data = null;
|
|
280
|
+
try { data = row.data ? JSON.parse(row.data) : null; } catch { data = null; }
|
|
281
|
+
return { purpose: row.purpose, player: row.player, data };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The challenge a browser's clientDataJSON carries (what it signed), to find the row it belongs to. */
|
|
285
|
+
function challengeIn(credential) {
|
|
286
|
+
try {
|
|
287
|
+
const b64 = String(credential?.response?.clientDataJSON ?? '').replace(/-/g, '+').replace(/_/g, '/');
|
|
288
|
+
const client = JSON.parse(atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4)));
|
|
289
|
+
return typeof client?.challenge === 'string' ? client.challenge : null;
|
|
290
|
+
} catch { return null; }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ------------------------------------------------------------------ the player, as they and others see it */
|
|
294
|
+
|
|
295
|
+
async function meOf(env, player) {
|
|
296
|
+
const keys = await env.DB.prepare('SELECT COUNT(*) AS n FROM player_passkeys WHERE player = ?1').bind(player.id).first();
|
|
297
|
+
let email = null;
|
|
298
|
+
if (env.PLAYER_MAIL) {
|
|
299
|
+
const e = await env.DB.prepare('SELECT email, verified_at FROM player_emails WHERE player = ?1').bind(player.id).first();
|
|
300
|
+
if (e) email = { address: e.email, verified: Boolean(e.verified_at) };
|
|
301
|
+
}
|
|
302
|
+
return { id: player.id, name: player.name, named: Boolean(player.named), guest: Boolean(player.guest), owner: Boolean(player.owner), since: new Date(player.since).toISOString(), passkeys: Number(keys?.n) || 0, email };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const features = (env) => ({ accounts: Boolean(env.DB) && env.HOMIE_PREVIEW !== '1', email: Boolean(env.PLAYER_MAIL && env.PLAYER_MAIL_FROM) });
|
|
306
|
+
|
|
307
|
+
/** Remove a player and everything of theirs (their saves' large objects too). */
|
|
308
|
+
async function removePlayer(env, id) {
|
|
309
|
+
const data = await dropPlayerData(env, id);
|
|
310
|
+
await env.DB.batch([
|
|
311
|
+
...data.statements,
|
|
312
|
+
env.DB.prepare('DELETE FROM player_passkeys WHERE player = ?1').bind(id),
|
|
313
|
+
env.DB.prepare('DELETE FROM player_sessions WHERE player = ?1').bind(id),
|
|
314
|
+
env.DB.prepare('DELETE FROM player_challenges WHERE player = ?1').bind(id),
|
|
315
|
+
env.DB.prepare('DELETE FROM player_emails WHERE player = ?1').bind(id),
|
|
316
|
+
env.DB.prepare('DELETE FROM players WHERE id = ?1').bind(id),
|
|
317
|
+
]);
|
|
318
|
+
await data.after();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* ------------------------------------------------------------------ the back office's API */
|
|
322
|
+
|
|
323
|
+
const row = (r) => ({ id: r.id, name: r.name, guest: Number(r.guest) === 1, owner: Number(r.owner) === 1, createdAt: Number(r.created_at), seenAt: Number(r.seen_at), passkeys: Number(r.passkeys) || 0 });
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* What the studio's back office (admin pages, owner MCP tools, room control) may use. Every function takes the
|
|
327
|
+
* Worker's `env`; none returns a passkey (credential id or key), a session or an email address.
|
|
328
|
+
*/
|
|
329
|
+
export const players = Object.freeze({
|
|
330
|
+
/** { accounts, guests, owners, new7d, active1d, active7d } */
|
|
331
|
+
async count(env) {
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
const r = await env.DB.prepare('SELECT SUM(guest = 0) AS accounts, SUM(guest = 1) AS guests, SUM(owner = 1) AS owners, SUM(guest = 0 AND created_at >= ?1) AS new7d, SUM(seen_at >= ?2) AS active1d, SUM(seen_at >= ?1) AS active7d FROM players')
|
|
334
|
+
.bind(now - 7 * DAY, now - DAY).first();
|
|
335
|
+
return { accounts: Number(r?.accounts) || 0, guests: Number(r?.guests) || 0, owners: Number(r?.owners) || 0, new7d: Number(r?.new7d) || 0, active1d: Number(r?.active1d) || 0, active7d: Number(r?.active7d) || 0 };
|
|
336
|
+
},
|
|
337
|
+
/** Newest first: { players: [{ id, name, guest, owner, createdAt, seenAt, passkeys }], next } (pass `next` as `cursor`). */
|
|
338
|
+
async list(env, { limit = 50, cursor = null, q = '', guests = false } = {}) {
|
|
339
|
+
const n = Math.max(1, Math.min(200, Math.floor(Number(limit)) || 50));
|
|
340
|
+
const where = [];
|
|
341
|
+
const args = [];
|
|
342
|
+
if (!guests) where.push('p.guest = 0');
|
|
343
|
+
const like = String(q ?? '').slice(0, 40).replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
344
|
+
if (like) { args.push(`%${like}%`); where.push(`p.name LIKE ?${args.length} ESCAPE '\\'`); }
|
|
345
|
+
const m = /^(\d{1,16}):(pl_[A-Za-z0-9_-]{22})$/.exec(String(cursor ?? ''));
|
|
346
|
+
if (m) { args.push(Number(m[1]), m[2]); where.push(`(p.created_at < ?${args.length - 1} OR (p.created_at = ?${args.length - 1} AND p.id < ?${args.length}))`); }
|
|
347
|
+
args.push(n + 1);
|
|
348
|
+
const rows = (await env.DB.prepare(`SELECT p.id, p.name, p.guest, p.owner, p.created_at, p.seen_at, (SELECT COUNT(*) FROM player_passkeys k WHERE k.player = p.id) AS passkeys FROM players p${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY p.created_at DESC, p.id DESC LIMIT ?${args.length}`).bind(...args).all()).results ?? [];
|
|
349
|
+
const page = rows.slice(0, n).map(row);
|
|
350
|
+
const last = rows.length > n ? page[page.length - 1] : null;
|
|
351
|
+
return { players: page, next: last ? `${last.createdAt}:${last.id}` : null };
|
|
352
|
+
},
|
|
353
|
+
/**
|
|
354
|
+
* One player: the list's fields plus saves per game (keys, bytes, last write), lifetime stats and memorial count.
|
|
355
|
+
* `get(env, id)`; `get(id, env)` works too (the back office's usePlayers seam passes the id first).
|
|
356
|
+
*/
|
|
357
|
+
async get(env, id) {
|
|
358
|
+
if (typeof env === 'string' && id && typeof id === 'object') [env, id] = [id, env];
|
|
359
|
+
if (!PLAYER_ID.test(String(id))) return null;
|
|
360
|
+
const r = await env.DB.prepare('SELECT p.id, p.name, p.guest, p.owner, p.created_at, p.seen_at, (SELECT COUNT(*) FROM player_passkeys k WHERE k.player = p.id) AS passkeys FROM players p WHERE p.id = ?1').bind(id).first();
|
|
361
|
+
if (!r) return null;
|
|
362
|
+
const stats = (await env.DB.prepare('SELECT game, name, n FROM player_stats WHERE player = ?1').bind(id).all()).results ?? [];
|
|
363
|
+
const mem = await env.DB.prepare('SELECT COUNT(*) AS n FROM memorials WHERE player = ?1').bind(id).first();
|
|
364
|
+
const byGame = {};
|
|
365
|
+
for (const s of stats) (byGame[s.game] ??= {})[s.name] = Number(s.n);
|
|
366
|
+
return { ...row(r), games: await savesSummary(env, id), stats: byGame, memorials: Number(mem?.n) || 0 };
|
|
367
|
+
},
|
|
368
|
+
/** The request's signed-in player ({ id, name, guest, owner }), or null. */
|
|
369
|
+
async of(request, env) {
|
|
370
|
+
try { const s = await sessionOf(request, env); return s ? { id: s.player.id, name: s.player.name, guest: s.player.guest, owner: s.player.owner } : null; } catch { return null; }
|
|
371
|
+
},
|
|
372
|
+
/** True when the request carries the session of an account its owner marked (`homie-studio players owner`). */
|
|
373
|
+
async isOwner(request, env) { return Boolean((await players.of(request, env))?.owner); },
|
|
374
|
+
/** A short-lived pass naming a player (for a room to recognise them): 'pp_…'. */
|
|
375
|
+
async passFor(env, id, { game = null, minutes = PLAYER_LIMITS.passMinutes } = {}) {
|
|
376
|
+
const pass = `pp_${randomToken(24)}`;
|
|
377
|
+
await env.DB.prepare("INSERT INTO player_sessions (hash, player, kind, game, expires_at) VALUES (?1, ?2, 'pass', ?3, ?4)")
|
|
378
|
+
.bind(await sha256Hex(pass), id, game && GAME_ID.test(game) ? game : null, Date.now() + Math.max(1, Math.min(60, Number(minutes) || 10)) * MINUTE).run();
|
|
379
|
+
return pass;
|
|
380
|
+
},
|
|
381
|
+
/** { id, name, guest, owner, game } for a live pass, or null. */
|
|
382
|
+
async readPass(env, pass) {
|
|
383
|
+
if (!PASS.test(String(pass ?? '')) || !env?.DB) return null;
|
|
384
|
+
const r = await env.DB.prepare("SELECT s.player, s.game, s.expires_at, p.name, p.guest, p.owner FROM player_sessions s JOIN players p ON p.id = s.player WHERE s.hash = ?1 AND s.kind = 'pass'").bind(await sha256Hex(pass)).first();
|
|
385
|
+
if (!r || Number(r.expires_at) <= Date.now()) return null;
|
|
386
|
+
return { id: r.player, name: r.name, guest: Number(r.guest) === 1, owner: Number(r.owner) === 1, game: r.game ?? null };
|
|
387
|
+
},
|
|
388
|
+
/** The owner renames a player (moderation): the same rules as a player's own name, except roles. */
|
|
389
|
+
async rename(env, id, name) {
|
|
390
|
+
const c = cleanName(name, { owner: true });
|
|
391
|
+
if (!c.ok) return c;
|
|
392
|
+
const r = await env.DB.prepare('UPDATE players SET name = ?2, named = 1 WHERE id = ?1 RETURNING id').bind(id, c.name).first();
|
|
393
|
+
return r ? { ok: true, name: c.name } : { ok: false, error: 'unknown', message: 'no such player' };
|
|
394
|
+
},
|
|
395
|
+
/** Delete a player and everything of theirs (as their own Delete does). */
|
|
396
|
+
async remove(env, id) {
|
|
397
|
+
if (!PLAYER_ID.test(String(id))) return { ok: false, error: 'unknown' };
|
|
398
|
+
await removePlayer(env, id);
|
|
399
|
+
return { ok: true };
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
/* ------------------------------------------------------------------ requests */
|
|
404
|
+
|
|
405
|
+
/** A POST from this site's own pages: its Origin (or, when a browser sends none, Sec-Fetch-Site), and JSON. */
|
|
406
|
+
function sameOrigin(request, url) {
|
|
407
|
+
const origin = request.headers.get('origin');
|
|
408
|
+
if (origin && origin !== 'null') return origin === url.origin;
|
|
409
|
+
return request.headers.get('sec-fetch-site') === 'same-origin';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function bodyOf(request) {
|
|
413
|
+
if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? '')) return { error: fail(415, 'json', 'send JSON (content-type: application/json)') };
|
|
414
|
+
if (Number(request.headers.get('content-length') ?? 0) > PLAYER_LIMITS.bodyBytes) return { error: fail(413, 'too-large', 'that request is too large') };
|
|
415
|
+
const text = await request.text();
|
|
416
|
+
if (text.length > PLAYER_LIMITS.bodyBytes) return { error: fail(413, 'too-large', 'that request is too large') };
|
|
417
|
+
try { return { body: text ? JSON.parse(text) : {} }; } catch { return { error: fail(400, 'json', 'that is not JSON') }; }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const withCookies = (res, cookies) => {
|
|
421
|
+
for (const c of cookies ?? []) res.headers.append('set-cookie', c);
|
|
422
|
+
return res;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/** A site path to come back to after signing in (this site only). */
|
|
426
|
+
export function nextOf(raw) {
|
|
427
|
+
const s = String(raw ?? '');
|
|
428
|
+
return /^\/[A-Za-z0-9/_.\-~%]*(\?[A-Za-z0-9=&_.\-%]*)?$/.test(s) && !s.startsWith('//') ? s.slice(0, 200) : '/account/';
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function labelOf(raw, request) {
|
|
432
|
+
const given = cleanName(raw, { owner: true, max: 32 });
|
|
433
|
+
if (given.ok) return given.name;
|
|
434
|
+
const ua = request.headers.get('user-agent') ?? '';
|
|
435
|
+
return /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Macintosh/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows' : /CrOS/.test(ua) ? 'Chromebook' : /Linux/.test(ua) ? 'Linux' : 'Passkey';
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The games of this studio (the catalogue), for which saves may be kept. */
|
|
439
|
+
async function gameOf(catalogueOf, id) {
|
|
440
|
+
if (!GAME_ID.test(String(id ?? ''))) return null;
|
|
441
|
+
const cat = await catalogueOf();
|
|
442
|
+
return (cat.games ?? []).find((g) => g.id === id) ?? null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/* ------------------------------------------------------------------ routes */
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* /account/... and /api/player/... (or null when the path is neither). `catalogueOf()` reads games.json.
|
|
449
|
+
*/
|
|
450
|
+
export async function playerRoutes(request, env, ctx, url, { catalogueOf }) {
|
|
451
|
+
const path = url.pathname;
|
|
452
|
+
const read = request.method === 'GET' || request.method === 'HEAD';
|
|
453
|
+
if (path === '/_homie/account.js') return new Response(ACCOUNT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
|
|
454
|
+
if (path === '/account') return Response.redirect(`${url.origin}/account/${url.search}`, 301);
|
|
455
|
+
if (path === '/account/') {
|
|
456
|
+
if (!read) return fail(405, 'method', 'GET only');
|
|
457
|
+
const cat = await catalogueOf();
|
|
458
|
+
return accountPage(cat, { origin: url.origin, next: nextOf(url.searchParams.get('next')), owner: OWNER_KEY.test(url.searchParams.get('owner') ?? '') ? url.searchParams.get('owner') : null, link: LINK.test(url.searchParams.get('k') ?? '') ? url.searchParams.get('k') : null, mode: ['verify', 'recover'].includes(url.searchParams.get('do')) ? url.searchParams.get('do') : null, features: features(env) });
|
|
459
|
+
}
|
|
460
|
+
if (!path.startsWith('/api/player/')) return null;
|
|
461
|
+
const f = features(env);
|
|
462
|
+
if (!f.accounts) return fail(503, 'no-accounts', env.HOMIE_PREVIEW === '1' ? 'Player accounts work on the studio\'s live site; a Preview has no database.' : 'This studio has no database bound (DB).');
|
|
463
|
+
if (!read && !sameOrigin(request, url)) return fail(403, 'origin', 'only this site\'s own pages may do that');
|
|
464
|
+
try {
|
|
465
|
+
return await apiRoute(request, env, ctx, url, path.slice('/api/player/'.length).split('/').filter(Boolean), { catalogueOf, read, f });
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (/no such table/i.test(String(error?.message))) return fail(503, 'not-migrated', 'This studio\'s database has no player tables yet: `npm run deploy` applies migration 0004_players.sql.');
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f }) {
|
|
473
|
+
const [head, second, third] = parts;
|
|
474
|
+
const ip = addressOf(request);
|
|
475
|
+
const ceremony = () => limited(`cer:${ip}`, PLAYER_LIMITS.ceremoniesPerTenMinutes, 10 * MINUTE);
|
|
476
|
+
const studioName = async () => (await catalogueOf()).studio?.name ?? 'Studio';
|
|
477
|
+
|
|
478
|
+
if (head === 'me' && parts.length === 1) {
|
|
479
|
+
if (!read) return fail(405, 'method', 'GET only');
|
|
480
|
+
const s = await sessionOf(request, env, url);
|
|
481
|
+
return withCookies(json({ ok: true, player: s ? await meOf(env, s.player) : null, features: f, limits: { saves: SAVE_LIMITS } }), s?.cookies);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (read && head === 'fallen' && second && parts.length === 2) {
|
|
485
|
+
if (!(await gameOf(catalogueOf, second))) return fail(404, 'game', 'no such game here');
|
|
486
|
+
if (limited(`rd:${ip}`, PLAYER_LIMITS.readsPerMinute, MINUTE)) return fail(429, 'rate', 'too many requests; slow down');
|
|
487
|
+
const mine = url.searchParams.get('mine') === '1';
|
|
488
|
+
const s = mine ? await sessionOf(request, env, url) : null;
|
|
489
|
+
if (mine && !s) return json({ ok: true, game: second, fallen: [] });
|
|
490
|
+
return json({ ok: true, game: second, fallen: await fallenList(env, second, { limit: url.searchParams.get('limit'), player: s?.player.id ?? null }) });
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (read && head === 'export' && parts.length === 1) {
|
|
494
|
+
const s = await sessionOf(request, env, url);
|
|
495
|
+
if (!s) return fail(401, 'signed-out', 'nothing to export: this browser has no player here');
|
|
496
|
+
const me = await meOf(env, s.player);
|
|
497
|
+
const keys = (await env.DB.prepare('SELECT label, synced, created_at, used_at FROM player_passkeys WHERE player = ?1 ORDER BY created_at').bind(s.player.id).all()).results ?? [];
|
|
498
|
+
const body = {
|
|
499
|
+
v: 1, kind: 'homie-studio-player', studio: await studioName(), site: url.origin, exportedAt: new Date().toISOString(),
|
|
500
|
+
player: me,
|
|
501
|
+
passkeys: keys.map((k) => ({ label: k.label, synced: Number(k.synced) === 1, createdAt: new Date(Number(k.created_at)).toISOString(), lastUsedAt: new Date(Number(k.used_at)).toISOString() })),
|
|
502
|
+
games: await playerData(env, s.player.id),
|
|
503
|
+
note: 'Everything this studio keeps about this player. A passkey\'s private key never left your device; this site keeps only its public key.',
|
|
504
|
+
};
|
|
505
|
+
return json(body, 200, { 'content-disposition': `attachment; filename="${s.player.id}.json"` });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (read && head === 'passkeys' && parts.length === 1) {
|
|
509
|
+
const s = await sessionOf(request, env, url);
|
|
510
|
+
if (!s) return json({ ok: true, passkeys: [] });
|
|
511
|
+
const keys = (await env.DB.prepare('SELECT id, label, synced, created_at, used_at FROM player_passkeys WHERE player = ?1 ORDER BY created_at').bind(s.player.id).all()).results ?? [];
|
|
512
|
+
return json({ ok: true, passkeys: keys.map((k) => ({ id: k.id.slice(0, 12), label: k.label, synced: Number(k.synced) === 1, createdAt: Number(k.created_at), usedAt: Number(k.used_at) })) });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Saves, stats and memorials of one game: GET reads, POST writes (a guest is made on the first write).
|
|
516
|
+
if (head === 'saves' || head === 'stats' || (head === 'fallen' && !read)) {
|
|
517
|
+
const game = await gameOf(catalogueOf, second);
|
|
518
|
+
if (!game) return fail(404, 'game', 'no such game here');
|
|
519
|
+
let s = await sessionOf(request, env, url);
|
|
520
|
+
if (read) {
|
|
521
|
+
if (head !== 'saves' && head !== 'stats') return fail(404, 'path', 'not found');
|
|
522
|
+
if (s && limited(`rd:${s.player.id}`, PLAYER_LIMITS.readsPerMinute, MINUTE)) return fail(429, 'rate', 'too many reads; slow down');
|
|
523
|
+
if (head === 'stats') return withCookies(json({ ok: true, game: game.id, stats: s ? await readPlayerStats(env, s.player.id, game.id) : {} }), s?.cookies);
|
|
524
|
+
if (third && parts.length === 3) {
|
|
525
|
+
if (!s) return json({ ok: true, key: third, value: null, version: 0, updatedAt: null });
|
|
526
|
+
const r = await getSave(env, s.player.id, game.id, third);
|
|
527
|
+
return withCookies(json(r, r.ok ? 200 : 400), s.cookies);
|
|
528
|
+
}
|
|
529
|
+
if (!s) return json({ ok: true, game: game.id, player: null, keys: [], used: { keys: 0, bytes: 0, blobBytes: 0 }, limits: SAVE_LIMITS });
|
|
530
|
+
const list = await listSaves(env, s.player.id, game.id, { values: url.searchParams.get('values') === '1' });
|
|
531
|
+
return withCookies(json({ ok: true, game: game.id, player: await meOf(env, s.player), ...list, limits: SAVE_LIMITS }), s.cookies);
|
|
532
|
+
}
|
|
533
|
+
const b = await bodyOf(request);
|
|
534
|
+
if (b.error) return b.error;
|
|
535
|
+
if (!s) {
|
|
536
|
+
const made = await newGuest(request, env, url);
|
|
537
|
+
if (made.error) return made.error;
|
|
538
|
+
s = made.session;
|
|
539
|
+
}
|
|
540
|
+
if (limited(`wr:${s.player.id}`, PLAYER_LIMITS.writesPerMinute, MINUTE)) return withCookies(fail(429, 'rate', 'too many saves; the game should save less often'), s.cookies);
|
|
541
|
+
const me = { id: s.player.id, name: s.player.name, guest: s.player.guest, owner: s.player.owner, fresh: Boolean(s.fresh) };
|
|
542
|
+
if (head === 'saves' && third === 'wipe' && parts.length === 3) {
|
|
543
|
+
return withCookies(json({ ...(await wipeSaves(env, s.player.id, game.id, { keep: b.body.keep })), player: me }), s.cookies);
|
|
544
|
+
}
|
|
545
|
+
if (head === 'saves' && parts.length === 2) {
|
|
546
|
+
const r = await writeSaves(env, s.player.id, game.id, { set: b.body.set, del: b.body.del });
|
|
547
|
+
return withCookies(json({ ...r, player: me }, r.ok ? 200 : 400), s.cookies);
|
|
548
|
+
}
|
|
549
|
+
if (head === 'stats' && parts.length === 2) {
|
|
550
|
+
const r = await bumpPlayerStats(env, s.player.id, game.id, b.body);
|
|
551
|
+
return withCookies(json({ ...r, player: me }, r.ok ? 200 : 400), s.cookies);
|
|
552
|
+
}
|
|
553
|
+
if (head === 'fallen' && parts.length === 2) {
|
|
554
|
+
const c = cleanName(b.body.character, { owner: true, max: 32 });
|
|
555
|
+
if (!c.ok) return withCookies(fail(400, 'character', `the fallen character needs a name (${c.message})`), s.cookies);
|
|
556
|
+
const r = await fall(env, s.player.id, s.player.name, game.id, { character: c.name, summary: b.body.summary ?? {}, wipe: b.body.wipe === true, keep: b.body.keep });
|
|
557
|
+
return withCookies(json({ ...r, player: me }, r.ok ? 200 : r.error === 'rate' ? 429 : 400), s.cookies);
|
|
558
|
+
}
|
|
559
|
+
return withCookies(fail(404, 'path', 'not found'), s.cookies);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (read) return fail(404, 'path', 'not found');
|
|
563
|
+
const b = await bodyOf(request);
|
|
564
|
+
if (b.error) return b.error;
|
|
565
|
+
const body = b.body ?? {};
|
|
566
|
+
|
|
567
|
+
/* ---------------- passkey ceremonies */
|
|
568
|
+
|
|
569
|
+
if (head === 'signup' && second === 'options' && parts.length === 2) {
|
|
570
|
+
if (ceremony()) return fail(429, 'rate', 'too many tries from this address; wait a few minutes');
|
|
571
|
+
const s = await sessionOf(request, env, url);
|
|
572
|
+
const rp = { name: (await studioName()).slice(0, 60), id: url.hostname };
|
|
573
|
+
if (s) {
|
|
574
|
+
const count = await env.DB.prepare('SELECT COUNT(*) AS n FROM player_passkeys WHERE player = ?1').bind(s.player.id).first();
|
|
575
|
+
if (Number(count?.n) >= PLAYER_LIMITS.passkeys) return fail(409, 'passkeys', `an account keeps at most ${PLAYER_LIMITS.passkeys} passkeys; remove one first`);
|
|
576
|
+
const wanted = body.name === undefined || body.name === '' ? null : cleanName(body.name, { studio: rp.name, owner: s.player.owner });
|
|
577
|
+
if (wanted && !wanted.ok) return fail(400, wanted.error, wanted.message);
|
|
578
|
+
const existing = (await env.DB.prepare('SELECT id FROM player_passkeys WHERE player = ?1').bind(s.player.id).all()).results ?? [];
|
|
579
|
+
const challenge = await issueChallenge(env, 'add', s.player.id, { name: wanted?.name ?? null });
|
|
580
|
+
const name = wanted?.name ?? s.player.name;
|
|
581
|
+
return json({ ok: true, upgrade: s.player.guest, options: creationOptions(challenge, rp, s.player.id, name, existing.map((k) => k.id)) });
|
|
582
|
+
}
|
|
583
|
+
const wanted = body.name === undefined || body.name === '' ? null : cleanName(body.name, { studio: rp.name });
|
|
584
|
+
if (wanted && !wanted.ok) return fail(400, wanted.error, wanted.message);
|
|
585
|
+
const id = newPlayerId();
|
|
586
|
+
const name = wanted?.name ?? handleFor(id);
|
|
587
|
+
const challenge = await issueChallenge(env, 'signup', id, { name, named: Boolean(wanted) });
|
|
588
|
+
return json({ ok: true, upgrade: false, options: creationOptions(challenge, rp, id, name, []) });
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (head === 'signup' && parts.length === 1) {
|
|
592
|
+
const credential = body.credential;
|
|
593
|
+
const s = await sessionOf(request, env, url);
|
|
594
|
+
// A name typed after the options were fetched (the page fetches them ahead, so a tap reaches the prompt at once).
|
|
595
|
+
const typed = body.name === undefined || body.name === '' || body.name === null ? null : cleanName(body.name, { studio: await studioName(), owner: Boolean(s?.player.owner) });
|
|
596
|
+
if (typed && !typed.ok) return fail(400, typed.error, typed.message);
|
|
597
|
+
const spent = await spendChallenge(env, challengeIn(credential), ['signup', 'add', 'recover']);
|
|
598
|
+
if (!spent) return fail(400, 'challenge', 'that try has expired; start again');
|
|
599
|
+
if (typed && spent.purpose !== 'recover') spent.data = { ...(spent.data ?? {}), name: typed.name, named: true };
|
|
600
|
+
if (spent.purpose === 'add' && s?.player.id !== spent.player) return fail(403, 'session', 'sign in again, then add the passkey');
|
|
601
|
+
// Options fetched before this browser's first save made it a guest: the passkey upgrades that guest.
|
|
602
|
+
if (spent.purpose === 'signup' && s?.player.guest) { spent.purpose = 'add'; spent.player = s.player.id; }
|
|
603
|
+
else if (spent.purpose === 'signup' && s) return fail(409, 'session', 'this browser is signed in already: reload the page');
|
|
604
|
+
const v = await verifyRegistration(credential?.response ? { ...credential.response, id: credential.id } : null, { challenge: challengeIn(credential), site: url.origin, rpId: url.hostname });
|
|
605
|
+
if (!v.ok) return fail(400, 'passkey', `the passkey could not be checked (${v.error})`);
|
|
606
|
+
if (await env.DB.prepare('SELECT 1 AS x FROM player_passkeys WHERE id = ?1').bind(v.id).first()) return fail(409, 'known', 'that passkey already has an account here: sign in with it');
|
|
607
|
+
const now = Date.now();
|
|
608
|
+
const label = labelOf(body.label, request);
|
|
609
|
+
const key = env.DB.prepare('INSERT INTO player_passkeys (id, player, public_key, alg, sign_count, label, synced, created_at, used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)')
|
|
610
|
+
.bind(v.id, spent.player, JSON.stringify(v.jwk), v.alg, v.signCount, label, v.backedUp ? 1 : 0, now);
|
|
611
|
+
if (spent.purpose === 'signup') {
|
|
612
|
+
if (limited(`new:${ip}`, PLAYER_LIMITS.newPlayersPerHour, HOUR)) return fail(429, 'rate', 'too many new players from this address; try again later');
|
|
613
|
+
if (await dailyCapReached(env)) return fail(503, 'busy', 'this studio has taken all the new players it takes in one day; try again tomorrow');
|
|
614
|
+
const session = await mintSession(env, url, spent.player, false);
|
|
615
|
+
await env.DB.batch([
|
|
616
|
+
env.DB.prepare('INSERT INTO players (id, name, named, guest, owner, created_at, seen_at) VALUES (?1, ?2, ?3, 0, 0, ?4, ?4)').bind(spent.player, spent.data?.name ?? handleFor(spent.player), spent.data?.named ? 1 : 0, now),
|
|
617
|
+
key, session.statement,
|
|
618
|
+
]);
|
|
619
|
+
const player = { id: spent.player, name: spent.data?.name ?? handleFor(spent.player), named: Boolean(spent.data?.named), guest: false, owner: false, since: now };
|
|
620
|
+
return withCookies(json({ ok: true, made: 'account', player: await meOf(env, player) }), [session.cookie]);
|
|
621
|
+
}
|
|
622
|
+
const writes = [key, env.DB.prepare('UPDATE players SET guest = 0, seen_at = ?2 WHERE id = ?1').bind(spent.player, now)];
|
|
623
|
+
if (spent.data?.name) writes.push(env.DB.prepare('UPDATE players SET name = ?2, named = 1 WHERE id = ?1').bind(spent.player, spent.data.name));
|
|
624
|
+
const cookies = [];
|
|
625
|
+
if (spent.purpose === 'recover') {
|
|
626
|
+
// A recovery link proved the email; the old sessions end, and this browser signs in.
|
|
627
|
+
writes.push(env.DB.prepare("DELETE FROM player_sessions WHERE player = ?1 AND kind = 'session'").bind(spent.player));
|
|
628
|
+
const session = await mintSession(env, url, spent.player, false);
|
|
629
|
+
writes.push(session.statement);
|
|
630
|
+
cookies.push(session.cookie);
|
|
631
|
+
} else if (s?.player.guest) {
|
|
632
|
+
// The guest's session becomes an account's: same token, an account's lifetime.
|
|
633
|
+
writes.push(env.DB.prepare('UPDATE player_sessions SET expires_at = ?2 WHERE hash = ?1').bind(s.hash, now + PLAYER_LIMITS.sessionDays * DAY));
|
|
634
|
+
cookies.push(sessionCookie(url, s.token, PLAYER_LIMITS.sessionDays));
|
|
635
|
+
}
|
|
636
|
+
await env.DB.batch(writes);
|
|
637
|
+
const p = await env.DB.prepare('SELECT id, name, named, guest, owner, created_at FROM players WHERE id = ?1').bind(spent.player).first();
|
|
638
|
+
if (!p) return fail(404, 'unknown', 'that account is gone');
|
|
639
|
+
return withCookies(json({ ok: true, made: spent.purpose === 'recover' ? 'recovered' : s?.player.guest ? 'upgraded' : 'passkey', player: await meOf(env, { id: p.id, name: p.name, named: Number(p.named) === 1, guest: false, owner: Number(p.owner) === 1, since: Number(p.created_at) }) }), cookies);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (head === 'signin' && second === 'options' && parts.length === 2) {
|
|
643
|
+
if (ceremony()) return fail(429, 'rate', 'too many tries from this address; wait a few minutes');
|
|
644
|
+
const challenge = await issueChallenge(env, 'signin');
|
|
645
|
+
// No allowCredentials: the device offers whichever passkey it holds for this site (a discoverable credential).
|
|
646
|
+
return json({ ok: true, options: { challenge, rpId: url.hostname, userVerification: 'preferred', timeout: PLAYER_LIMITS.challengeMinutes * MINUTE } });
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (head === 'signin' && parts.length === 1) {
|
|
650
|
+
const credential = body.credential;
|
|
651
|
+
const spent = await spendChallenge(env, challengeIn(credential), ['signin']);
|
|
652
|
+
if (!spent) return fail(400, 'challenge', 'that try has expired; start again');
|
|
653
|
+
if (!CRED_ID.test(String(credential?.id ?? ''))) return fail(400, 'passkey', 'that is not a passkey');
|
|
654
|
+
const key = await env.DB.prepare('SELECT k.id, k.player, k.public_key, k.alg, k.sign_count, p.name, p.named, p.owner, p.created_at FROM player_passkeys k JOIN players p ON p.id = k.player WHERE k.id = ?1').bind(credential.id).first();
|
|
655
|
+
// The same answer for "no such passkey" and "a bad signature": neither tells a stranger which passkeys exist.
|
|
656
|
+
const refused = fail(401, 'unknown', 'this passkey is not an account here (it may have been deleted); make an account instead');
|
|
657
|
+
if (!key) return refused;
|
|
658
|
+
const v = await verifyAssertion(credential.response, { alg: key.alg, jwk: key.public_key, signCount: key.sign_count }, { challenge: challengeIn(credential), site: url.origin, rpId: url.hostname });
|
|
659
|
+
if (!v.ok) return v.error === 'counter' ? fail(401, 'counter', 'this passkey looks copied; sign in with another one') : refused;
|
|
660
|
+
const now = Date.now();
|
|
661
|
+
const current = await sessionOf(request, env, url);
|
|
662
|
+
const session = await mintSession(env, url, key.player, false);
|
|
663
|
+
const writes = [
|
|
664
|
+
env.DB.prepare('UPDATE player_passkeys SET sign_count = ?2, used_at = ?3 WHERE id = ?1').bind(key.id, v.signCount, now),
|
|
665
|
+
env.DB.prepare('UPDATE players SET seen_at = ?2 WHERE id = ?1').bind(key.player, now),
|
|
666
|
+
session.statement,
|
|
667
|
+
];
|
|
668
|
+
let adopted = null;
|
|
669
|
+
if (current && current.player.id !== key.player) {
|
|
670
|
+
writes.push(env.DB.prepare('DELETE FROM player_sessions WHERE hash = ?1').bind(current.hash));
|
|
671
|
+
// A guest signing in to their account: what only the guest had moves over, then the guest is gone.
|
|
672
|
+
if (current.player.guest) {
|
|
673
|
+
adopted = await adoptStatements(env, current.player.id, key.player);
|
|
674
|
+
writes.push(...adopted.statements,
|
|
675
|
+
env.DB.prepare('DELETE FROM player_sessions WHERE player = ?1').bind(current.player.id),
|
|
676
|
+
env.DB.prepare('DELETE FROM player_challenges WHERE player = ?1').bind(current.player.id),
|
|
677
|
+
env.DB.prepare('DELETE FROM players WHERE id = ?1 AND guest = 1').bind(current.player.id));
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
await env.DB.batch(writes);
|
|
681
|
+
if (adopted) await adopted.after();
|
|
682
|
+
const player = { id: key.player, name: key.name, named: Number(key.named) === 1, guest: false, owner: Number(key.owner) === 1, since: Number(key.created_at) };
|
|
683
|
+
return withCookies(json({ ok: true, player: await meOf(env, player), adoptedGuest: Boolean(adopted) }), [session.cookie]);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/* ---------------- a recovery email, signed out: the link in the mail is the proof */
|
|
687
|
+
|
|
688
|
+
if (head === 'recover' || (head === 'email' && second === 'verify')) {
|
|
689
|
+
if (!f.email) return fail(404, 'no-email', 'this studio has no mail sender, so no recovery email: sign in with a passkey your device synced, or another passkey you added');
|
|
690
|
+
if (head === 'email') {
|
|
691
|
+
const spent = await spendChallenge(env, String(body.k ?? ''), ['email-verify']);
|
|
692
|
+
if (!spent) return fail(400, 'link', 'that link has been used or has expired');
|
|
693
|
+
await env.DB.prepare('UPDATE player_emails SET verified_at = ?3 WHERE player = ?1 AND email = ?2').bind(spent.player, spent.data?.email ?? '', Date.now()).run();
|
|
694
|
+
return json({ ok: true, verified: true });
|
|
695
|
+
}
|
|
696
|
+
if (second === 'options' && parts.length === 2) {
|
|
697
|
+
if (ceremony()) return fail(429, 'rate', 'too many tries from this address; wait a few minutes');
|
|
698
|
+
const spent = await spendChallenge(env, String(body.k ?? ''), ['recover-link']);
|
|
699
|
+
if (!spent) return fail(400, 'link', 'that link has been used or has expired; ask for a new one');
|
|
700
|
+
const p = await env.DB.prepare('SELECT id, name FROM players WHERE id = ?1').bind(spent.player).first();
|
|
701
|
+
if (!p) return fail(404, 'unknown', 'that account is gone');
|
|
702
|
+
const existing = (await env.DB.prepare('SELECT id FROM player_passkeys WHERE player = ?1').bind(p.id).all()).results ?? [];
|
|
703
|
+
const challenge = await issueChallenge(env, 'recover', p.id, null);
|
|
704
|
+
return json({ ok: true, options: creationOptions(challenge, { name: (await studioName()).slice(0, 60), id: url.hostname }, p.id, p.name, existing.map((k) => k.id)) });
|
|
705
|
+
}
|
|
706
|
+
if (parts.length !== 1) return fail(404, 'path', 'not found');
|
|
707
|
+
const email = emailOf(body.email);
|
|
708
|
+
if (!email) return fail(400, 'email', 'that is not an email address');
|
|
709
|
+
if (limited(`mail:${ip}`, PLAYER_LIMITS.mailsPerHour, HOUR) || limited(`mailto:${email}`, 3, HOUR)) return fail(429, 'rate', 'too many emails asked for; try again in an hour');
|
|
710
|
+
const owner = await env.DB.prepare('SELECT player FROM player_emails WHERE email = ?1 AND verified_at IS NOT NULL').bind(email).first();
|
|
711
|
+
// The same answer either way: whether an address has an account here is nobody else's business.
|
|
712
|
+
const answer = json({ ok: true, message: 'If that address belongs to an account here, a link is on its way. It works once, for 30 minutes.' });
|
|
713
|
+
if (!owner) return answer;
|
|
714
|
+
const k = await issueChallenge(env, 'recover-link', owner.player, null, PLAYER_LIMITS.linkMinutes);
|
|
715
|
+
await sendMail(env, email, `Sign in to ${await studioName()}`, `Someone (hopefully you) asked to get back into your player account on ${await studioName()}.\n\nOpen this link on the device you want to use, and make a new passkey there:\n${url.origin}/account/?do=recover&k=${k}\n\nIt works once, for 30 minutes. If you did not ask, ignore this email: nothing changes.\n`).catch(() => {});
|
|
716
|
+
return answer;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/* ---------------- signed-in only */
|
|
720
|
+
|
|
721
|
+
const s = await sessionOf(request, env, url);
|
|
722
|
+
|
|
723
|
+
if (head === 'signout' && parts.length === 1) {
|
|
724
|
+
if (s) await env.DB.prepare('DELETE FROM player_sessions WHERE hash = ?1').bind(s.hash).run();
|
|
725
|
+
return withCookies(json({ ok: true, player: null }), [clearCookie(url)]);
|
|
726
|
+
}
|
|
727
|
+
if (!s) return fail(401, 'signed-out', 'this browser has no player here: sign in first');
|
|
728
|
+
|
|
729
|
+
if (head === 'name' && parts.length === 1) {
|
|
730
|
+
const c = cleanName(body.name, { studio: await studioName(), owner: s.player.owner });
|
|
731
|
+
if (!c.ok) return withCookies(fail(400, c.error, c.message), s.cookies);
|
|
732
|
+
await env.DB.prepare('UPDATE players SET name = ?2, named = 1 WHERE id = ?1').bind(s.player.id, c.name).run();
|
|
733
|
+
return withCookies(json({ ok: true, player: await meOf(env, { ...s.player, name: c.name, named: true }), cut: c.cut }), s.cookies);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (head === 'passkeys' && second === 'remove' && parts.length === 2) {
|
|
737
|
+
const prefix = String(body.id ?? '');
|
|
738
|
+
if (!/^[A-Za-z0-9_-]{8,1400}$/.test(prefix)) return fail(400, 'passkey', 'which passkey?');
|
|
739
|
+
const keys = (await env.DB.prepare('SELECT id FROM player_passkeys WHERE player = ?1').bind(s.player.id).all()).results ?? [];
|
|
740
|
+
const match = keys.filter((k) => k.id.startsWith(prefix));
|
|
741
|
+
if (match.length !== 1) return fail(404, 'passkey', 'no such passkey on this account');
|
|
742
|
+
if (keys.length === 1) return fail(409, 'last-passkey', 'this is the account\'s only passkey: add another first, or delete the account');
|
|
743
|
+
await env.DB.prepare('DELETE FROM player_passkeys WHERE id = ?1 AND player = ?2').bind(match[0].id, s.player.id).run();
|
|
744
|
+
return withCookies(json({ ok: true, removed: match[0].id }), s.cookies);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (head === 'pass' && parts.length === 1) {
|
|
748
|
+
if (limited(`pass:${s.player.id}`, 30, HOUR)) return fail(429, 'rate', 'too many passes; one lasts ten minutes');
|
|
749
|
+
if (body.game !== undefined && !(await gameOf(catalogueOf, body.game))) return fail(404, 'game', 'no such game here');
|
|
750
|
+
return withCookies(json({ ok: true, pass: await players.passFor(env, s.player.id, { game: body.game ?? null }), minutes: PLAYER_LIMITS.passMinutes }), s.cookies);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (head === 'owner' && parts.length === 1) {
|
|
754
|
+
if (s.player.guest) return fail(403, 'guest', 'make an account (a passkey) first: the owner\'s account must be one they can sign in to');
|
|
755
|
+
const k = String(body.k ?? '');
|
|
756
|
+
if (!OWNER_KEY.test(k)) return fail(400, 'link', 'that owner link is not one');
|
|
757
|
+
const row = await env.DB.prepare("DELETE FROM stats_keys WHERE hash = ?1 AND kind = 'player-owner' RETURNING expires_at").bind(await sha256Hex(k)).first();
|
|
758
|
+
if (!row || Number(row.expires_at) <= Date.now()) return fail(403, 'link', 'that owner link has been used or has expired; ask your AI for a new one: npx --no-install homie-studio players owner');
|
|
759
|
+
await env.DB.prepare('UPDATE players SET owner = 1 WHERE id = ?1').bind(s.player.id).run();
|
|
760
|
+
return withCookies(json({ ok: true, player: await meOf(env, { ...s.player, owner: true }) }), s.cookies);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (head === 'delete' && parts.length === 1) {
|
|
764
|
+
if (body.confirm !== 'delete') return fail(400, 'confirm', 'send { "confirm": "delete" } to delete this player and everything they kept');
|
|
765
|
+
await removePlayer(env, s.player.id);
|
|
766
|
+
return withCookies(json({ ok: true, deleted: s.player.id }), [clearCookie(url)]);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/* ---------------- a recovery email (only with a mail sender: PLAYER_MAIL + PLAYER_MAIL_FROM) */
|
|
770
|
+
|
|
771
|
+
if (head === 'email') {
|
|
772
|
+
if (!f.email) return fail(404, 'no-email', 'this studio has no mail sender, so no recovery email');
|
|
773
|
+
if (second === 'remove' && parts.length === 2) {
|
|
774
|
+
await env.DB.prepare('DELETE FROM player_emails WHERE player = ?1').bind(s.player.id).run();
|
|
775
|
+
return withCookies(json({ ok: true, player: await meOf(env, s.player) }), s.cookies);
|
|
776
|
+
}
|
|
777
|
+
if (parts.length !== 1) return fail(404, 'path', 'not found');
|
|
778
|
+
if (s.player.guest) return fail(403, 'guest', 'make an account (a passkey) first; the email is how that account comes back');
|
|
779
|
+
const email = emailOf(body.email);
|
|
780
|
+
if (!email) return fail(400, 'email', 'that is not an email address');
|
|
781
|
+
if (limited(`mail:${ip}`, PLAYER_LIMITS.mailsPerHour, HOUR) || limited(`mailp:${s.player.id}`, 3, HOUR)) return fail(429, 'rate', 'too many emails asked for; try again in an hour');
|
|
782
|
+
await env.DB.prepare('INSERT INTO player_emails (player, email, verified_at, added_at) VALUES (?1, ?2, NULL, ?3) ON CONFLICT(player) DO UPDATE SET email = excluded.email, verified_at = NULL, added_at = excluded.added_at').bind(s.player.id, email, Date.now()).run();
|
|
783
|
+
const k = await issueChallenge(env, 'email-verify', s.player.id, { email }, PLAYER_LIMITS.linkMinutes);
|
|
784
|
+
try {
|
|
785
|
+
await sendMail(env, email, `Confirm your recovery email for ${await studioName()}`, `Confirm this address for your player account on ${await studioName()}, so you can get back in if you lose your passkeys:\n${url.origin}/account/?do=verify&k=${k}\n\nIt works once, for 30 minutes. If this was not you, ignore it.\n`);
|
|
786
|
+
} catch (error) {
|
|
787
|
+
return fail(502, 'mail', `the email could not be sent (${String(error?.message ?? error).slice(0, 80)})`);
|
|
788
|
+
}
|
|
789
|
+
return withCookies(json({ ok: true, sent: true, player: await meOf(env, s.player) }), s.cookies);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return fail(404, 'path', 'not found');
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** An email address to keep or look up: trimmed, lowercased, or null. */
|
|
796
|
+
function emailOf(raw) {
|
|
797
|
+
const e = String(raw ?? '').trim().toLowerCase();
|
|
798
|
+
return e.length <= 254 && EMAIL.test(e) ? e : null;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Cloudflare Email Service (a `send_email` binding named PLAYER_MAIL, and PLAYER_MAIL_FROM on a domain it sends for). */
|
|
802
|
+
async function sendMail(env, to, subject, text) {
|
|
803
|
+
await env.PLAYER_MAIL.send({ from: env.PLAYER_MAIL_FROM, to, subject, text });
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** navigator.credentials.create options (JSON-safe: the browser script turns the base64url fields into bytes). */
|
|
807
|
+
function creationOptions(challenge, rp, playerId, name, exclude) {
|
|
808
|
+
return {
|
|
809
|
+
challenge, rp,
|
|
810
|
+
user: { id: bytesToB64url(new TextEncoder().encode(playerId)), name, displayName: name },
|
|
811
|
+
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
|
|
812
|
+
authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'preferred' },
|
|
813
|
+
attestation: 'none',
|
|
814
|
+
timeout: PLAYER_LIMITS.challengeMinutes * MINUTE,
|
|
815
|
+
excludeCredentials: exclude.map((id) => ({ type: 'public-key', id })),
|
|
816
|
+
};
|
|
817
|
+
}
|