@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
package/worker/saves.mjs
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLOUD SAVES, LIFETIME STATS AND MEMORIALS — per player, per game, in the studio's own D1 (saves/SAVES.md).
|
|
3
|
+
*
|
|
4
|
+
* saves key → JSON value, versioned (every write adds one), per player and game. A write may name the
|
|
5
|
+
* version it was based on (`base`): if another device wrote in between, it is refused with the
|
|
6
|
+
* current value (a conflict), so a stale offline copy never overwrites newer progress silently.
|
|
7
|
+
* player_stats numbers that only add up (or keep the highest or lowest) and survive a wipe: time played, gold
|
|
8
|
+
* earned, kills, best level. A hardcore death wipes the saves and keeps these.
|
|
9
|
+
* memorials the hall of the fallen: a character's name, the player's display name and a small summary,
|
|
10
|
+
* written when a hardcore character dies (optionally wiping that game's saves in the same step).
|
|
11
|
+
*
|
|
12
|
+
* LIMITS (SAVE_LIMITS): 64 KiB a value, 64 keys and 256 KiB a game per player in D1. With the studio's storage (R2,
|
|
13
|
+
* `homie-studio storage add`) a value up to 1 MiB goes to R2 instead (4 MiB a game per player), under a random
|
|
14
|
+
* object key that the public /media/ route refuses. Keys are 1 to 64 of A-Z a-z 0-9 _ . : -.
|
|
15
|
+
* A write is atomic per key, not across keys: keep a character in ONE key.
|
|
16
|
+
*/
|
|
17
|
+
import { randomToken } from './webauthn.mjs';
|
|
18
|
+
|
|
19
|
+
export const SAVE_LIMITS = Object.freeze({
|
|
20
|
+
valueBytes: 64 * 1024, gameBytes: 256 * 1024, keys: 64, batch: 32,
|
|
21
|
+
blobBytes: 1024 * 1024, gameBlobBytes: 4 * 1024 * 1024,
|
|
22
|
+
stats: 64, memorialBytes: 2048, memorialsPerDay: 20, fallenList: 50,
|
|
23
|
+
});
|
|
24
|
+
export const SAVE_KEY = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
25
|
+
export const STAT_NAME = /^[A-Za-z0-9_.:-]{1,32}$/;
|
|
26
|
+
const BLOB = /^r2:(players\/[A-Za-z0-9_-]{16,64})$/;
|
|
27
|
+
const ENC = new TextEncoder();
|
|
28
|
+
const bytesOf = (text) => ENC.encode(text).length;
|
|
29
|
+
|
|
30
|
+
/** The R2 object a blob row points at, or null for a value kept in D1. */
|
|
31
|
+
const blobKey = (row) => (row && Number(row.blob) === 1 ? BLOB.exec(String(row.value))?.[1] ?? null : null);
|
|
32
|
+
|
|
33
|
+
async function valueOf(env, row) {
|
|
34
|
+
const key = blobKey(row);
|
|
35
|
+
if (!key) { try { return JSON.parse(row.value); } catch { return null; } }
|
|
36
|
+
if (!env.MEDIA) return null;
|
|
37
|
+
const obj = await env.MEDIA.get(key);
|
|
38
|
+
if (!obj) return null;
|
|
39
|
+
try { return JSON.parse(await obj.text()); } catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function dropBlobs(env, keys) {
|
|
43
|
+
const list = keys.filter(Boolean);
|
|
44
|
+
if (!list.length || !env.MEDIA) return;
|
|
45
|
+
try { await env.MEDIA.delete(list); } catch { /* a missing object is already gone */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Every key of one player's game, with its version and size; `values` adds each value. */
|
|
49
|
+
export async function listSaves(env, player, game, { values = false } = {}) {
|
|
50
|
+
const rows = (await env.DB.prepare('SELECT key, value, bytes, version, updated_at, blob FROM saves WHERE player = ?1 AND game = ?2 ORDER BY key LIMIT 200').bind(player, game).all()).results ?? [];
|
|
51
|
+
const keys = [];
|
|
52
|
+
for (const r of rows) {
|
|
53
|
+
const k = { key: r.key, version: Number(r.version), bytes: Number(r.bytes), updatedAt: Number(r.updated_at) };
|
|
54
|
+
if (values) k.value = await valueOf(env, r);
|
|
55
|
+
keys.push(k);
|
|
56
|
+
}
|
|
57
|
+
return { keys, used: usage(rows) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function usage(rows) {
|
|
61
|
+
let bytes = 0; let blobBytes = 0;
|
|
62
|
+
for (const r of rows) { if (Number(r.blob) === 1) blobBytes += Number(r.bytes); else bytes += Number(r.bytes); }
|
|
63
|
+
return { keys: rows.length, bytes, blobBytes };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function getSave(env, player, game, key) {
|
|
67
|
+
if (!SAVE_KEY.test(String(key))) return { ok: false, error: 'key', message: 'a save key is 1 to 64 letters, digits, _ . : or -' };
|
|
68
|
+
const row = await env.DB.prepare('SELECT value, bytes, version, updated_at, blob FROM saves WHERE player = ?1 AND game = ?2 AND key = ?3').bind(player, game, key).first();
|
|
69
|
+
if (!row) return { ok: true, key, value: null, version: 0, updatedAt: null };
|
|
70
|
+
return { ok: true, key, value: await valueOf(env, row), version: Number(row.version), updatedAt: Number(row.updated_at) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Write and delete keys of one player's game. Each item: { key, value, base? } to set, { key, base? } to delete.
|
|
75
|
+
* `base` is the version the change was made from (0: the key did not exist); without it the write is
|
|
76
|
+
* unconditional. Returns one result per item: { key, ok: true, version } or { key, ok: false, error, current? }.
|
|
77
|
+
*/
|
|
78
|
+
export async function writeSaves(env, player, game, { set = [], del = [] } = {}) {
|
|
79
|
+
const items = [...(Array.isArray(set) ? set : []).map((s) => ({ ...s, op: 'set' })), ...(Array.isArray(del) ? del : []).map((s) => ({ ...s, op: 'del' }))];
|
|
80
|
+
if (!items.length) return { ok: true, results: [] };
|
|
81
|
+
if (items.length > SAVE_LIMITS.batch) return { ok: false, error: 'batch', message: `at most ${SAVE_LIMITS.batch} keys in one write` };
|
|
82
|
+
const rows = (await env.DB.prepare('SELECT key, value, bytes, version, blob FROM saves WHERE player = ?1 AND game = ?2').bind(player, game).all()).results ?? [];
|
|
83
|
+
const now = new Map(rows.map((r) => [r.key, r]));
|
|
84
|
+
const results = [];
|
|
85
|
+
const at = Date.now();
|
|
86
|
+
for (const item of items) {
|
|
87
|
+
const key = String(item?.key ?? '');
|
|
88
|
+
if (!SAVE_KEY.test(key)) { results.push({ key, ok: false, error: 'key', message: 'a save key is 1 to 64 letters, digits, _ . : or -' }); continue; }
|
|
89
|
+
const base = item.base === undefined || item.base === null ? null : Math.floor(Number(item.base));
|
|
90
|
+
if (base !== null && !(base >= 0)) { results.push({ key, ok: false, error: 'base', message: 'base is the version the change was made from (0 when the key is new)' }); continue; }
|
|
91
|
+
const prev = now.get(key) ?? null;
|
|
92
|
+
if (base !== null && (prev ? Number(prev.version) : 0) !== base) {
|
|
93
|
+
results.push({ key, ok: false, error: 'conflict', message: 'another device saved this key since', current: prev ? { value: await valueOf(env, prev), version: Number(prev.version) } : { value: null, version: 0 } });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (item.op === 'del') {
|
|
97
|
+
if (!prev) { results.push({ key, ok: true, version: 0 }); continue; }
|
|
98
|
+
const gone = await env.DB.prepare('DELETE FROM saves WHERE player = ?1 AND game = ?2 AND key = ?3 AND version = ?4 RETURNING version').bind(player, game, key, Number(prev.version)).first();
|
|
99
|
+
if (!gone) { results.push({ key, ok: false, error: 'conflict', message: 'another device saved this key since' }); continue; }
|
|
100
|
+
await dropBlobs(env, [blobKey(prev)]);
|
|
101
|
+
now.delete(key);
|
|
102
|
+
results.push({ key, ok: true, version: 0 });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (item.value === undefined) { results.push({ key, ok: false, error: 'value', message: 'a save needs a value (null is a value; use del to remove a key)' }); continue; }
|
|
106
|
+
let text;
|
|
107
|
+
try { text = JSON.stringify(item.value); } catch { results.push({ key, ok: false, error: 'value', message: 'a save is JSON' }); continue; }
|
|
108
|
+
const size = bytesOf(text);
|
|
109
|
+
const others = [...now.values()].filter((r) => r.key !== key);
|
|
110
|
+
const used = usage(others);
|
|
111
|
+
if (!prev && others.length >= SAVE_LIMITS.keys) { results.push({ key, ok: false, error: 'too-many-keys', message: `a game keeps at most ${SAVE_LIMITS.keys} keys per player` }); continue; }
|
|
112
|
+
let stored = text; let blob = 0; let newObject = null;
|
|
113
|
+
if (size > SAVE_LIMITS.valueBytes) {
|
|
114
|
+
if (!env.MEDIA) { results.push({ key, ok: false, error: 'too-large', message: `a save is at most ${SAVE_LIMITS.valueBytes / 1024} KiB (this studio has no storage for larger ones)` }); continue; }
|
|
115
|
+
if (size > SAVE_LIMITS.blobBytes) { results.push({ key, ok: false, error: 'too-large', message: `a save is at most ${SAVE_LIMITS.blobBytes / 1024} KiB` }); continue; }
|
|
116
|
+
if (used.blobBytes + size > SAVE_LIMITS.gameBlobBytes) { results.push({ key, ok: false, error: 'full', message: `a game keeps at most ${SAVE_LIMITS.gameBlobBytes / 1048576} MiB of large saves per player` }); continue; }
|
|
117
|
+
newObject = `players/${randomToken(24)}`;
|
|
118
|
+
await env.MEDIA.put(newObject, text, { httpMetadata: { contentType: 'application/json' } });
|
|
119
|
+
stored = `r2:${newObject}`; blob = 1;
|
|
120
|
+
} else if (used.bytes + size > SAVE_LIMITS.gameBytes) {
|
|
121
|
+
results.push({ key, ok: false, error: 'full', message: `a game keeps at most ${SAVE_LIMITS.gameBytes / 1024} KiB of saves per player` });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
let row;
|
|
125
|
+
if (base === null) {
|
|
126
|
+
row = await env.DB.prepare('INSERT INTO saves (player, game, key, value, bytes, version, updated_at, blob) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7) ON CONFLICT(player, game, key) DO UPDATE SET value = excluded.value, bytes = excluded.bytes, version = saves.version + 1, updated_at = excluded.updated_at, blob = excluded.blob RETURNING version')
|
|
127
|
+
.bind(player, game, key, stored, size, at, blob).first();
|
|
128
|
+
} else if (base === 0) {
|
|
129
|
+
row = await env.DB.prepare('INSERT INTO saves (player, game, key, value, bytes, version, updated_at, blob) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7) ON CONFLICT(player, game, key) DO NOTHING RETURNING version')
|
|
130
|
+
.bind(player, game, key, stored, size, at, blob).first();
|
|
131
|
+
} else {
|
|
132
|
+
row = await env.DB.prepare('UPDATE saves SET value = ?4, bytes = ?5, version = version + 1, updated_at = ?6, blob = ?7 WHERE player = ?1 AND game = ?2 AND key = ?3 AND version = ?8 RETURNING version')
|
|
133
|
+
.bind(player, game, key, stored, size, at, blob, base).first();
|
|
134
|
+
}
|
|
135
|
+
if (!row) {
|
|
136
|
+
await dropBlobs(env, [newObject]);
|
|
137
|
+
results.push({ key, ok: false, error: 'conflict', message: 'another device saved this key since' });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (prev) await dropBlobs(env, [blobKey(prev)]);
|
|
141
|
+
now.set(key, { key, value: stored, bytes: size, version: Number(row.version), blob });
|
|
142
|
+
results.push({ key, ok: true, version: Number(row.version) });
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, results, used: usage([...now.values()]) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Every save of one player's game, except the `keep` keys: a hardcore death, or "new game". */
|
|
148
|
+
export function wipeStatements(env, player, game, keep = []) {
|
|
149
|
+
const kept = (Array.isArray(keep) ? keep : []).filter((k) => SAVE_KEY.test(String(k))).slice(0, SAVE_LIMITS.keys);
|
|
150
|
+
const marks = kept.map((_, i) => `?${i + 3}`).join(', ');
|
|
151
|
+
return env.DB.prepare(`DELETE FROM saves WHERE player = ?1 AND game = ?2${kept.length ? ` AND key NOT IN (${marks})` : ''} RETURNING key, value, blob`).bind(player, game, ...kept);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function wipeSaves(env, player, game, { keep = [] } = {}) {
|
|
155
|
+
const gone = (await wipeStatements(env, player, game, keep).all()).results ?? [];
|
|
156
|
+
await dropBlobs(env, gone.map(blobKey));
|
|
157
|
+
return { ok: true, wiped: gone.map((r) => r.key) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ------------------------------------------------------------------ lifetime stats */
|
|
161
|
+
|
|
162
|
+
export async function readPlayerStats(env, player, game) {
|
|
163
|
+
const rows = (await env.DB.prepare('SELECT name, n FROM player_stats WHERE player = ?1 AND game = ?2 ORDER BY name LIMIT 100').bind(player, game).all()).results ?? [];
|
|
164
|
+
return Object.fromEntries(rows.map((r) => [r.name, Number(r.n)]));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Add to counters (`add`), keep the highest (`max`) or the lowest (`min`): { add: { kills: 1 }, max: { level: 7 } }.
|
|
169
|
+
* Numbers only, finite, |n| < 1e15. They never go back on their own: a wipe keeps them.
|
|
170
|
+
*/
|
|
171
|
+
export async function bumpPlayerStats(env, player, game, { add = {}, max = {}, min = {} } = {}) {
|
|
172
|
+
const ops = [];
|
|
173
|
+
for (const [kind, obj] of [['add', add], ['max', max], ['min', min]]) {
|
|
174
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) continue;
|
|
175
|
+
for (const [name, raw] of Object.entries(obj)) {
|
|
176
|
+
const n = Number(raw);
|
|
177
|
+
if (!STAT_NAME.test(name)) return { ok: false, error: 'stat', message: `a stat name is 1 to 32 letters, digits, _ . : or - (got ${JSON.stringify(String(name).slice(0, 40))})` };
|
|
178
|
+
if (!Number.isFinite(n) || Math.abs(n) >= 1e15) return { ok: false, error: 'stat', message: `${name} is not a number a stat can hold` };
|
|
179
|
+
if (kind === 'add' && n === 0) continue;
|
|
180
|
+
ops.push({ kind, name, n });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (ops.length > SAVE_LIMITS.stats) return { ok: false, error: 'stat', message: `at most ${SAVE_LIMITS.stats} stats at once` };
|
|
184
|
+
if (!ops.length) return { ok: true, stats: await readPlayerStats(env, player, game) };
|
|
185
|
+
const have = await readPlayerStats(env, player, game);
|
|
186
|
+
const fresh = new Set(ops.map((o) => o.name).filter((name) => !(name in have)));
|
|
187
|
+
if (Object.keys(have).length + fresh.size > SAVE_LIMITS.stats) return { ok: false, error: 'stat', message: `a game keeps at most ${SAVE_LIMITS.stats} stats per player` };
|
|
188
|
+
const at = Date.now();
|
|
189
|
+
const sql = {
|
|
190
|
+
add: 'INSERT INTO player_stats (player, game, name, n, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(player, game, name) DO UPDATE SET n = player_stats.n + excluded.n, updated_at = excluded.updated_at',
|
|
191
|
+
max: 'INSERT INTO player_stats (player, game, name, n, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(player, game, name) DO UPDATE SET n = max(player_stats.n, excluded.n), updated_at = excluded.updated_at',
|
|
192
|
+
min: 'INSERT INTO player_stats (player, game, name, n, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(player, game, name) DO UPDATE SET n = min(player_stats.n, excluded.n), updated_at = excluded.updated_at',
|
|
193
|
+
};
|
|
194
|
+
await env.DB.batch(ops.map((o) => env.DB.prepare(sql[o.kind]).bind(player, game, o.name, o.n, at)));
|
|
195
|
+
return { ok: true, stats: await readPlayerStats(env, player, game) };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* ------------------------------------------------------------------ the hall of the fallen */
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A memorial: { character, summary, wipe, keep }. `character` follows the display-name rules (the caller cleans
|
|
202
|
+
* it); `summary` is a small JSON object (level, cause, gold, days survived…), 2 KiB at most. `wipe: true` deletes
|
|
203
|
+
* this game's saves (except `keep`) in the SAME transaction, so a death and its wipe can never half-happen.
|
|
204
|
+
*/
|
|
205
|
+
export async function fall(env, player, playerName, game, { character, summary = {}, wipe = false, keep = [] }) {
|
|
206
|
+
if (summary === null || typeof summary !== 'object' || Array.isArray(summary)) return { ok: false, error: 'summary', message: 'a memorial summary is a JSON object' };
|
|
207
|
+
const text = JSON.stringify(summary);
|
|
208
|
+
if (bytesOf(text) > SAVE_LIMITS.memorialBytes) return { ok: false, error: 'too-large', message: `a memorial summary is at most ${SAVE_LIMITS.memorialBytes} bytes` };
|
|
209
|
+
const at = Date.now();
|
|
210
|
+
const today = await env.DB.prepare('SELECT COUNT(*) AS n FROM memorials WHERE player = ?1 AND game = ?2 AND at >= ?3').bind(player, game, at - 86400_000).first();
|
|
211
|
+
if (Number(today?.n) >= SAVE_LIMITS.memorialsPerDay) return { ok: false, error: 'rate', message: `at most ${SAVE_LIMITS.memorialsPerDay} memorials a day per player` };
|
|
212
|
+
const insert = env.DB.prepare('INSERT INTO memorials (player, game, character, player_name, summary, at) VALUES (?1, ?2, ?3, ?4, ?5, ?6) RETURNING id').bind(player, game, character, playerName, text, at);
|
|
213
|
+
const [made, wiped] = await env.DB.batch(wipe ? [insert, wipeStatements(env, player, game, keep)] : [insert]);
|
|
214
|
+
const gone = wiped?.results ?? [];
|
|
215
|
+
await dropBlobs(env, gone.map(blobKey));
|
|
216
|
+
return { ok: true, memorial: { id: Number(made?.results?.[0]?.id ?? 0), character, player: playerName, summary, at }, wiped: gone.map((r) => r.key) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The newest memorials of a game (everyone's), or one player's own: { character, player, summary, at }. */
|
|
220
|
+
export async function fallenList(env, game, { limit = 20, player = null } = {}) {
|
|
221
|
+
const n = Math.max(1, Math.min(SAVE_LIMITS.fallenList, Math.floor(Number(limit)) || 20));
|
|
222
|
+
const rows = (await (player
|
|
223
|
+
? env.DB.prepare('SELECT character, player_name, summary, at FROM memorials WHERE game = ?1 AND player = ?2 ORDER BY at DESC LIMIT ?3').bind(game, player, n)
|
|
224
|
+
: env.DB.prepare('SELECT character, player_name, summary, at FROM memorials WHERE game = ?1 ORDER BY at DESC LIMIT ?2').bind(game, n)).all()).results ?? [];
|
|
225
|
+
return rows.map((r) => {
|
|
226
|
+
let summary = {};
|
|
227
|
+
try { summary = JSON.parse(r.summary); } catch { summary = {}; }
|
|
228
|
+
return { character: r.character, player: r.player_name, summary, at: Number(r.at) };
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ------------------------------------------------------------------ a player's whole data */
|
|
233
|
+
|
|
234
|
+
/** Everything this player has in every game, for their export (values included, large ones from R2). */
|
|
235
|
+
export async function playerData(env, player) {
|
|
236
|
+
const rows = (await env.DB.prepare('SELECT game, key, value, bytes, version, updated_at, blob FROM saves WHERE player = ?1 ORDER BY game, key').bind(player).all()).results ?? [];
|
|
237
|
+
const stats = (await env.DB.prepare('SELECT game, name, n, updated_at FROM player_stats WHERE player = ?1 ORDER BY game, name').bind(player).all()).results ?? [];
|
|
238
|
+
const mem = (await env.DB.prepare('SELECT game, character, player_name, summary, at FROM memorials WHERE player = ?1 ORDER BY at').bind(player).all()).results ?? [];
|
|
239
|
+
const games = {};
|
|
240
|
+
const of = (g) => (games[g] ??= { saves: {}, stats: {}, memorials: [] });
|
|
241
|
+
for (const r of rows) of(r.game).saves[r.key] = { value: await valueOf(env, r), version: Number(r.version), updatedAt: new Date(Number(r.updated_at)).toISOString() };
|
|
242
|
+
for (const s of stats) of(s.game).stats[s.name] = Number(s.n);
|
|
243
|
+
for (const m of mem) {
|
|
244
|
+
let summary = {};
|
|
245
|
+
try { summary = JSON.parse(m.summary); } catch { summary = {}; }
|
|
246
|
+
of(m.game).memorials.push({ character: m.character, as: m.player_name, summary, at: new Date(Number(m.at)).toISOString() });
|
|
247
|
+
}
|
|
248
|
+
return games;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Per game: keys, bytes and the last write, for the owner's view of a player (never the values). */
|
|
252
|
+
export async function savesSummary(env, player) {
|
|
253
|
+
const rows = (await env.DB.prepare('SELECT game, COUNT(*) AS keys, SUM(bytes) AS bytes, MAX(updated_at) AS at FROM saves WHERE player = ?1 GROUP BY game').bind(player).all()).results ?? [];
|
|
254
|
+
return rows.map((r) => ({ game: r.game, keys: Number(r.keys), bytes: Number(r.bytes) || 0, updatedAt: Number(r.at) || null }));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The statements that delete a player's saves, stats and memorials (and their large saves' objects, after). */
|
|
258
|
+
export async function dropPlayerData(env, player) {
|
|
259
|
+
const blobs = (await env.DB.prepare('SELECT value, blob FROM saves WHERE player = ?1 AND blob = 1').bind(player).all()).results ?? [];
|
|
260
|
+
return {
|
|
261
|
+
statements: [
|
|
262
|
+
env.DB.prepare('DELETE FROM saves WHERE player = ?1').bind(player),
|
|
263
|
+
env.DB.prepare('DELETE FROM player_stats WHERE player = ?1').bind(player),
|
|
264
|
+
env.DB.prepare('DELETE FROM memorials WHERE player = ?1').bind(player),
|
|
265
|
+
],
|
|
266
|
+
after: () => dropBlobs(env, blobs.map(blobKey)),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* A guest signs in to an account they already have: the account keeps what it has, and whatever only the guest
|
|
272
|
+
* had moves over (saves of keys the account lacks, stats it lacks, the guest's memorials). Statements for one
|
|
273
|
+
* batch, and the guest's large saves that the account already had (to drop after).
|
|
274
|
+
*/
|
|
275
|
+
export async function adoptStatements(env, from, to) {
|
|
276
|
+
const clash = (await env.DB.prepare('SELECT g.value, g.blob FROM saves g JOIN saves a ON a.player = ?2 AND a.game = g.game AND a.key = g.key WHERE g.player = ?1 AND g.blob = 1').bind(from, to).all()).results ?? [];
|
|
277
|
+
return {
|
|
278
|
+
statements: [
|
|
279
|
+
env.DB.prepare('INSERT OR IGNORE INTO saves (player, game, key, value, bytes, version, updated_at, blob) SELECT ?2, game, key, value, bytes, version, updated_at, blob FROM saves WHERE player = ?1').bind(from, to),
|
|
280
|
+
env.DB.prepare('INSERT OR IGNORE INTO player_stats (player, game, name, n, updated_at) SELECT ?2, game, name, n, updated_at FROM player_stats WHERE player = ?1').bind(from, to),
|
|
281
|
+
env.DB.prepare('UPDATE memorials SET player = ?2 WHERE player = ?1').bind(from, to),
|
|
282
|
+
env.DB.prepare('DELETE FROM saves WHERE player = ?1').bind(from),
|
|
283
|
+
env.DB.prepare('DELETE FROM player_stats WHERE player = ?1').bind(from),
|
|
284
|
+
],
|
|
285
|
+
after: () => dropBlobs(env, clash.map(blobKey)),
|
|
286
|
+
};
|
|
287
|
+
}
|
package/worker/site.mjs
CHANGED
|
@@ -912,7 +912,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
912
912
|
<a class="play" href="/${esc(g.id)}/play" data-play>${PLAY_ICON}<span>Play now — free</span></a>
|
|
913
913
|
${liveLine(g, playing, { idle: `Bots hold every empty seat · one tap and you are in` })}
|
|
914
914
|
</div>
|
|
915
|
-
${tvOn ? `<a class="also" href="#anywhere">${icon('tv')}<span>Or put it on the big screen</span></a>` : ''}${trailer ? `<a class="also" href="/videos/${esc(trailer.slug)}/">${icon('videos')}<span>Watch the trailer</span></a>` : ''}
|
|
915
|
+
${tvOn ? `<a class="also" href="#anywhere">${icon('tv')}<span>Or put it on the big screen</span></a>` : ''}${trailer ? `<a class="also" href="/videos/${esc(trailer.slug)}/">${icon('videos')}<span>Watch the trailer</span></a>` : ''}${g.saves ? `<a class="also" href="/account/?next=${encodeURIComponent(`/${g.id}/play`)}" data-account>${icon('spark')}<span>Your progress follows you: sign in</span></a>` : ''}
|
|
916
916
|
</div>
|
|
917
917
|
</section>`;
|
|
918
918
|
|
package/worker/stats-page.mjs
CHANGED
|
@@ -106,6 +106,7 @@ function statsBody(cat, s, url) {
|
|
|
106
106
|
${tile(t.visits, 'visits')}${tile(t.plays, 'Play presses')}${tile(t.rooms, 'rooms opened')}${tile(t.rounds, 'rounds finished')}
|
|
107
107
|
${tile(t.peopleInRounds, 'people in finished rounds')}${tile(t.peakPlayers, 'most playing at once')}${tile(t.playingNow, 'playing right now', t.playingNow ? 'live' : '')}
|
|
108
108
|
${tile(t.songPlays, 'songs played')}${tile(t.videoViews, 'videos watched')}
|
|
109
|
+
${s.players && (s.players.accounts || s.players.guests) ? `${tile(s.players.accounts, 'player accounts')}${tile(s.players.guests, 'guests with saves')}${tile(s.players.active7d, 'players this week')}` : ''}
|
|
109
110
|
</section>
|
|
110
111
|
<h2>Every day</h2><div class="bars" role="img" aria-label="visits and plays per day">${bars}</div>
|
|
111
112
|
<h2>Games</h2><div class="scroll"><table class="wide"><thead><tr><th>Game</th><th>Visits</th><th>Plays</th><th>Rooms</th><th>Rounds</th><th>People in rounds</th><th>Peak</th><th>Peak in a room</th><th>Now</th></tr></thead><tbody>${gameRows || '<tr><td colspan="9" class="dim">No games.</td></tr>'}</tbody></table></div>
|
package/worker/stats.mjs
CHANGED
|
@@ -200,6 +200,19 @@ export function rangeOf(params, now = Date.now()) {
|
|
|
200
200
|
return { from: dayOffset(end, -(days - 1)), to: end, days };
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Player accounts (0.12.0, worker/players.mjs): how many, never who. Null before migration 0004 or with no D1.
|
|
205
|
+
* `accounts` have a passkey; `guests` saved something without one; `active7d` played in the last 7 days.
|
|
206
|
+
*/
|
|
207
|
+
export async function playersNow(env) {
|
|
208
|
+
if (!env?.DB) return null;
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
try {
|
|
211
|
+
const r = await env.DB.prepare('SELECT SUM(guest = 0) AS accounts, SUM(guest = 1) AS guests, SUM(guest = 0 AND created_at >= ?1) AS new7d, SUM(seen_at >= ?1) AS active7d FROM players').bind(now - 7 * 86400_000).first();
|
|
212
|
+
return { accounts: Number(r?.accounts) || 0, guests: Number(r?.guests) || 0, newAccounts7d: Number(r?.new7d) || 0, active7d: Number(r?.active7d) || 0 };
|
|
213
|
+
} catch { return null; }
|
|
214
|
+
}
|
|
215
|
+
|
|
203
216
|
/** The Lobby's count of people in every room of each game, right now. */
|
|
204
217
|
async function playingNow(env, games) {
|
|
205
218
|
const out = {};
|
|
@@ -243,7 +256,7 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
|
|
|
243
256
|
const scoped = rows.filter(inScope);
|
|
244
257
|
const sum = (pred) => scoped.reduce((n, r) => n + (pred(r) ? Number(r.n) : 0), 0);
|
|
245
258
|
const maxOf = (pred) => scoped.reduce((n, r) => Math.max(n, pred(r) ? Number(r.n) : 0), 0);
|
|
246
|
-
const now = await playingNow(env, games);
|
|
259
|
+
const [now, people] = await Promise.all([playingNow(env, games), only ? null : playersNow(env)]);
|
|
247
260
|
const perGame = games.map((g) => {
|
|
248
261
|
const of = (m, extra = () => true) => sum((r) => r.metric === m && r.subject === g.id && extra(r));
|
|
249
262
|
return {
|
|
@@ -282,6 +295,8 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
|
|
|
282
295
|
peakPlayers: Math.max(0, ...perGame.map((g) => g.peakPlayers)), peakInOneRoom: Math.max(0, ...perGame.map((g) => g.peakInOneRoom)),
|
|
283
296
|
playingNow: perGame.reduce((n, g) => n + g.playingNow, 0),
|
|
284
297
|
},
|
|
298
|
+
// Player accounts (saves/SAVES.md): counts only, never a name, a passkey or an email.
|
|
299
|
+
...(people ? { players: people } : {}),
|
|
285
300
|
crossings: { fromHub: byKind('hub'), fromStudios: byKind('studio'), fromSearch: byKind('search'), fromWeb: byKind('web'), fromLinks: byKind('link') },
|
|
286
301
|
// The studio's own pages: its home, and the /music/ and /videos/ lists (a catalogue's road).
|
|
287
302
|
pages: Object.fromEntries(['home', 'games', 'rooms', 'posts', 'music', 'videos'].map((p) => [p, sum((r) => r.metric === 'visit' && r.subject === p)])),
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.12.0';
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PASSKEYS (WebAuthn), verified in the studio's own Worker with WebCrypto and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* What arrives from a browser is a PUBLIC key and signatures: worthless to anyone who copies them. The private key
|
|
5
|
+
* never leaves the player's device (or its own sync: iCloud Keychain, Google Password Manager, a password manager).
|
|
6
|
+
*
|
|
7
|
+
* Checked on every ceremony (each check is a refusal somebody could otherwise walk through):
|
|
8
|
+
* - clientData.type is the ceremony that was asked for (a registration cannot be replayed as a sign-in);
|
|
9
|
+
* - clientData.challenge is one this site issued and has not spent (the caller passes it);
|
|
10
|
+
* - clientData.origin is this site (an https origin, or a loopback one for `homie-studio dev`);
|
|
11
|
+
* - the authenticator data's rpIdHash is SHA-256 of this site's host name;
|
|
12
|
+
* - the user-present flag is set (somebody touched or looked at the device);
|
|
13
|
+
* - on sign-in, the signature over authenticatorData || SHA-256(clientDataJSON) verifies against the stored key,
|
|
14
|
+
* and the signature counter did not go backwards (a counter that moves back is a cloned authenticator; one
|
|
15
|
+
* that is always 0 is normal, for synced passkeys).
|
|
16
|
+
* Not checked, on purpose: attestation. Registration asks for `none`: a game studio has no reason to care which
|
|
17
|
+
* brand of authenticator a player holds, and refusing some would lock real people out.
|
|
18
|
+
*
|
|
19
|
+
* Keys: ES256 (COSE -7, P-256) and RS256 (COSE -257), stored as a JWK. A tiny CBOR reader takes what an
|
|
20
|
+
* attestation object and a COSE key contain (integers, byte and text strings, arrays, maps) and refuses the rest.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const ENC = new TextEncoder();
|
|
24
|
+
const DEC = new TextDecoder();
|
|
25
|
+
|
|
26
|
+
export function b64urlToBytes(text) {
|
|
27
|
+
const s = String(text ?? '');
|
|
28
|
+
if (!/^[A-Za-z0-9_-]*$/.test(s)) throw new Error('base64url');
|
|
29
|
+
const b64 = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
30
|
+
const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));
|
|
31
|
+
const out = new Uint8Array(bin.length);
|
|
32
|
+
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function bytesToB64url(bytes) {
|
|
37
|
+
let bin = '';
|
|
38
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
39
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function randomToken(bytes = 32) {
|
|
43
|
+
const raw = new Uint8Array(bytes);
|
|
44
|
+
crypto.getRandomValues(raw);
|
|
45
|
+
return bytesToB64url(raw);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function sha256(input) {
|
|
49
|
+
const bytes = typeof input === 'string' ? ENC.encode(input) : input;
|
|
50
|
+
return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function sha256Hex(text) {
|
|
54
|
+
return [...await sha256(text)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Equal bytes, in time that does not depend on where they differ. */
|
|
58
|
+
export function sameBytes(a, b) {
|
|
59
|
+
if (a.length !== b.length) return false;
|
|
60
|
+
let diff = 0;
|
|
61
|
+
for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];
|
|
62
|
+
return diff === 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ------------------------------------------------------------------ CBOR (just enough) */
|
|
66
|
+
|
|
67
|
+
export function cborDecode(bytes, at = 0, depth = 0) {
|
|
68
|
+
if (depth > 8) throw new Error('cbor-deep');
|
|
69
|
+
if (at >= bytes.length) throw new Error('cbor-short');
|
|
70
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
71
|
+
const head = bytes[at];
|
|
72
|
+
const major = head >> 5;
|
|
73
|
+
const minor = head & 31;
|
|
74
|
+
let n = minor;
|
|
75
|
+
let p = at + 1;
|
|
76
|
+
if (minor === 24) { n = bytes[p]; p += 1; }
|
|
77
|
+
else if (minor === 25) { n = view.getUint16(p); p += 2; }
|
|
78
|
+
else if (minor === 26) { n = view.getUint32(p); p += 4; }
|
|
79
|
+
else if (minor >= 27) throw new Error('cbor-unsupported-length');
|
|
80
|
+
if (p > bytes.length) throw new Error('cbor-short');
|
|
81
|
+
switch (major) {
|
|
82
|
+
case 0: return [n, p];
|
|
83
|
+
case 1: return [-1 - n, p];
|
|
84
|
+
case 2: if (p + n > bytes.length) throw new Error('cbor-short'); return [bytes.subarray(p, p + n), p + n];
|
|
85
|
+
case 3: if (p + n > bytes.length) throw new Error('cbor-short'); return [DEC.decode(bytes.subarray(p, p + n)), p + n];
|
|
86
|
+
case 4: {
|
|
87
|
+
if (n > 64) throw new Error('cbor-big');
|
|
88
|
+
const list = [];
|
|
89
|
+
for (let i = 0; i < n; i += 1) { const [v, q] = cborDecode(bytes, p, depth + 1); list.push(v); p = q; }
|
|
90
|
+
return [list, p];
|
|
91
|
+
}
|
|
92
|
+
case 5: {
|
|
93
|
+
if (n > 64) throw new Error('cbor-big');
|
|
94
|
+
const map = new Map();
|
|
95
|
+
for (let i = 0; i < n; i += 1) {
|
|
96
|
+
const [k, q] = cborDecode(bytes, p, depth + 1);
|
|
97
|
+
const [v, r] = cborDecode(bytes, q, depth + 1);
|
|
98
|
+
map.set(k, v);
|
|
99
|
+
p = r;
|
|
100
|
+
}
|
|
101
|
+
return [map, p];
|
|
102
|
+
}
|
|
103
|
+
default: throw new Error('cbor-unsupported');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ------------------------------------------------------------------ authenticator data */
|
|
108
|
+
|
|
109
|
+
/** rpIdHash (32) | flags (1) | signCount (4) | [aaguid (16) | idLength (2) | credentialId | COSE key]. */
|
|
110
|
+
export function parseAuthData(bytes) {
|
|
111
|
+
if (!(bytes instanceof Uint8Array) || bytes.length < 37) throw new Error('authdata-short');
|
|
112
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
113
|
+
const flags = bytes[32];
|
|
114
|
+
const out = {
|
|
115
|
+
rpIdHash: bytes.subarray(0, 32),
|
|
116
|
+
up: Boolean(flags & 0x01),
|
|
117
|
+
uv: Boolean(flags & 0x04),
|
|
118
|
+
backupEligible: Boolean(flags & 0x08),
|
|
119
|
+
backedUp: Boolean(flags & 0x10),
|
|
120
|
+
at: Boolean(flags & 0x40),
|
|
121
|
+
signCount: view.getUint32(33),
|
|
122
|
+
};
|
|
123
|
+
if (!out.at) return out;
|
|
124
|
+
if (bytes.length < 55) throw new Error('authdata-short');
|
|
125
|
+
const idLen = view.getUint16(53);
|
|
126
|
+
if (55 + idLen > bytes.length) throw new Error('authdata-short');
|
|
127
|
+
out.credentialId = bytes.subarray(55, 55 + idLen);
|
|
128
|
+
const [cose] = cborDecode(bytes, 55 + idLen);
|
|
129
|
+
out.cose = cose;
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A COSE public key as { alg, jwk }: ES256 (P-256) or RS256 only. */
|
|
134
|
+
export function coseToJwk(cose) {
|
|
135
|
+
if (!(cose instanceof Map)) throw new Error('cose');
|
|
136
|
+
const kty = cose.get(1);
|
|
137
|
+
const alg = cose.get(3);
|
|
138
|
+
if (kty === 2 && alg === -7) {
|
|
139
|
+
if (cose.get(-1) !== 1) throw new Error('cose-curve');
|
|
140
|
+
const x = cose.get(-2); const y = cose.get(-3);
|
|
141
|
+
if (!(x instanceof Uint8Array) || !(y instanceof Uint8Array) || x.length !== 32 || y.length !== 32) throw new Error('cose-ec');
|
|
142
|
+
return { alg, jwk: { kty: 'EC', crv: 'P-256', x: bytesToB64url(x), y: bytesToB64url(y) } };
|
|
143
|
+
}
|
|
144
|
+
if (kty === 3 && alg === -257) {
|
|
145
|
+
const nn = cose.get(-1); const e = cose.get(-2);
|
|
146
|
+
if (!(nn instanceof Uint8Array) || !(e instanceof Uint8Array) || nn.length < 256) throw new Error('cose-rsa');
|
|
147
|
+
return { alg, jwk: { kty: 'RSA', n: bytesToB64url(nn), e: bytesToB64url(e) } };
|
|
148
|
+
}
|
|
149
|
+
throw new Error('cose-alg');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function algorithms(alg) {
|
|
153
|
+
if (alg === -7) return { importAs: { name: 'ECDSA', namedCurve: 'P-256' }, verifyAs: { name: 'ECDSA', hash: 'SHA-256' } };
|
|
154
|
+
if (alg === -257) return { importAs: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, verifyAs: { name: 'RSASSA-PKCS1-v1_5' } };
|
|
155
|
+
throw new Error('alg');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** An ECDSA signature arrives DER-encoded; WebCrypto wants r || s, 32 bytes each. */
|
|
159
|
+
export function derToRaw(der) {
|
|
160
|
+
if (der[0] !== 0x30) throw new Error('der');
|
|
161
|
+
let p = 2;
|
|
162
|
+
if (der[1] & 0x80) p = 2 + (der[1] & 0x7f);
|
|
163
|
+
const int = () => {
|
|
164
|
+
if (der[p] !== 0x02) throw new Error('der-int');
|
|
165
|
+
const len = der[p + 1];
|
|
166
|
+
let v = der.subarray(p + 2, p + 2 + len);
|
|
167
|
+
p += 2 + len;
|
|
168
|
+
while (v.length > 32 && v[0] === 0) v = v.subarray(1);
|
|
169
|
+
if (v.length > 32) throw new Error('der-int');
|
|
170
|
+
const out = new Uint8Array(32);
|
|
171
|
+
out.set(v, 32 - v.length);
|
|
172
|
+
return out;
|
|
173
|
+
};
|
|
174
|
+
const raw = new Uint8Array(64);
|
|
175
|
+
raw.set(int(), 0);
|
|
176
|
+
raw.set(int(), 32);
|
|
177
|
+
return raw;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ------------------------------------------------------------------ ceremonies */
|
|
181
|
+
|
|
182
|
+
/** Is `origin` this site: the same origin as the request, https (or http on a loopback address for dev). */
|
|
183
|
+
export function originOk(origin, site) {
|
|
184
|
+
try {
|
|
185
|
+
const o = new URL(origin);
|
|
186
|
+
const s = new URL(site);
|
|
187
|
+
if (o.origin !== s.origin) return false;
|
|
188
|
+
return o.protocol === 'https:' || (o.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(o.hostname));
|
|
189
|
+
} catch { return false; }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** clientDataJSON: its type, challenge and origin. Returns { ok, client, bytes } or { ok: false, error }. */
|
|
193
|
+
function readClient(clientDataJSON, { type, challenge, site }) {
|
|
194
|
+
let bytes; let client;
|
|
195
|
+
try { bytes = b64urlToBytes(clientDataJSON); client = JSON.parse(DEC.decode(bytes)); } catch { return { ok: false, error: 'client-data' }; }
|
|
196
|
+
if (!client || client.type !== type) return { ok: false, error: 'ceremony' };
|
|
197
|
+
if (typeof client.challenge !== 'string' || !sameBytes(ENC.encode(client.challenge), ENC.encode(String(challenge ?? '')))) return { ok: false, error: 'challenge' };
|
|
198
|
+
if (!originOk(client.origin, site)) return { ok: false, error: 'origin' };
|
|
199
|
+
if (client.crossOrigin === true) return { ok: false, error: 'cross-origin' };
|
|
200
|
+
return { ok: true, client, bytes };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A registration (navigator.credentials.create): verifies it and returns the credential to keep:
|
|
205
|
+
* { ok, id, alg, jwk, signCount, backedUp } or { ok: false, error }.
|
|
206
|
+
*/
|
|
207
|
+
export async function verifyRegistration(response, { challenge, site, rpId }) {
|
|
208
|
+
if (!response || typeof response !== 'object') return { ok: false, error: 'response' };
|
|
209
|
+
const c = readClient(response.clientDataJSON, { type: 'webauthn.create', challenge, site });
|
|
210
|
+
if (!c.ok) return c;
|
|
211
|
+
let auth;
|
|
212
|
+
try {
|
|
213
|
+
const [att] = cborDecode(b64urlToBytes(response.attestationObject));
|
|
214
|
+
if (!(att instanceof Map) || !(att.get('authData') instanceof Uint8Array)) return { ok: false, error: 'attestation' };
|
|
215
|
+
auth = parseAuthData(att.get('authData'));
|
|
216
|
+
} catch (e) { return { ok: false, error: `attestation:${String(e.message).slice(0, 40)}` }; }
|
|
217
|
+
if (!sameBytes(auth.rpIdHash, await sha256(rpId))) return { ok: false, error: 'rp' };
|
|
218
|
+
if (!auth.up) return { ok: false, error: 'presence' };
|
|
219
|
+
if (!auth.at || !auth.credentialId?.length || auth.credentialId.length > 1023) return { ok: false, error: 'credential' };
|
|
220
|
+
let key;
|
|
221
|
+
try { key = coseToJwk(auth.cose); } catch (e) { return { ok: false, error: String(e.message) }; }
|
|
222
|
+
const id = bytesToB64url(auth.credentialId);
|
|
223
|
+
if (response.id && response.id !== id) return { ok: false, error: 'credential-id' };
|
|
224
|
+
// The key must import: a stored key that cannot verify would lock its player out later.
|
|
225
|
+
try { await crypto.subtle.importKey('jwk', key.jwk, algorithms(key.alg).importAs, false, ['verify']); } catch { return { ok: false, error: 'key' }; }
|
|
226
|
+
return { ok: true, id, alg: key.alg, jwk: key.jwk, signCount: auth.signCount, backedUp: auth.backedUp };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* A sign-in (navigator.credentials.get) against a stored credential { alg, jwk, signCount }:
|
|
231
|
+
* { ok, signCount, uv } or { ok: false, error }.
|
|
232
|
+
*/
|
|
233
|
+
export async function verifyAssertion(response, stored, { challenge, site, rpId }) {
|
|
234
|
+
if (!response || typeof response !== 'object' || !stored) return { ok: false, error: 'response' };
|
|
235
|
+
const c = readClient(response.clientDataJSON, { type: 'webauthn.get', challenge, site });
|
|
236
|
+
if (!c.ok) return c;
|
|
237
|
+
let authBytes; let auth; let sig;
|
|
238
|
+
try {
|
|
239
|
+
authBytes = b64urlToBytes(response.authenticatorData);
|
|
240
|
+
auth = parseAuthData(authBytes);
|
|
241
|
+
sig = b64urlToBytes(response.signature);
|
|
242
|
+
} catch { return { ok: false, error: 'assertion' }; }
|
|
243
|
+
if (!sameBytes(auth.rpIdHash, await sha256(rpId))) return { ok: false, error: 'rp' };
|
|
244
|
+
if (!auth.up) return { ok: false, error: 'presence' };
|
|
245
|
+
let ok = false;
|
|
246
|
+
try {
|
|
247
|
+
const { importAs, verifyAs } = algorithms(Number(stored.alg));
|
|
248
|
+
const key = await crypto.subtle.importKey('jwk', typeof stored.jwk === 'string' ? JSON.parse(stored.jwk) : stored.jwk, importAs, false, ['verify']);
|
|
249
|
+
const clientHash = await sha256(c.bytes);
|
|
250
|
+
const signed = new Uint8Array(authBytes.length + clientHash.length);
|
|
251
|
+
signed.set(authBytes, 0);
|
|
252
|
+
signed.set(clientHash, authBytes.length);
|
|
253
|
+
ok = await crypto.subtle.verify(verifyAs, key, Number(stored.alg) === -7 ? derToRaw(sig) : sig, signed);
|
|
254
|
+
} catch { ok = false; }
|
|
255
|
+
if (!ok) return { ok: false, error: 'signature' };
|
|
256
|
+
const before = Number(stored.signCount) || 0;
|
|
257
|
+
if ((auth.signCount !== 0 || before !== 0) && auth.signCount <= before) return { ok: false, error: 'counter' };
|
|
258
|
+
return { ok: true, signCount: auth.signCount, uv: auth.uv };
|
|
259
|
+
}
|