@nano133/nanoterra 0.8.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +92 -0
- package/README.md +390 -6
- package/ai/SPEC.md +71 -3
- package/dist/admin/panel/mount.d.ts +3 -0
- package/dist/admin/panel/mount.js +4 -2
- package/dist/player-signin/adapters/d1.d.ts +10 -0
- package/dist/player-signin/adapters/d1.js +108 -0
- package/dist/player-signin/adapters/firestore.d.ts +45 -0
- package/dist/player-signin/adapters/firestore.js +168 -0
- package/dist/player-signin/conformance.d.ts +11 -0
- package/dist/player-signin/conformance.js +160 -0
- package/dist/player-signin/handler.d.ts +64 -0
- package/dist/player-signin/handler.js +364 -0
- package/dist/player-signin/host.d.ts +4 -0
- package/dist/player-signin/host.js +10 -0
- package/dist/player-signin/index.d.ts +9 -0
- package/dist/player-signin/index.js +15 -0
- package/dist/player-signin/memory.d.ts +15 -0
- package/dist/player-signin/memory.js +101 -0
- package/dist/player-signin/panel/index.d.ts +9 -0
- package/dist/player-signin/panel/index.js +8 -0
- package/dist/player-signin/panel/memo.d.ts +12 -0
- package/dist/player-signin/panel/memo.js +41 -0
- package/dist/player-signin/panel/mount.d.ts +21 -0
- package/dist/player-signin/panel/mount.js +107 -0
- package/dist/player-signin/panel/view.d.ts +48 -0
- package/dist/player-signin/panel/view.js +363 -0
- package/dist/player-signin/panel/words.d.ts +103 -0
- package/dist/player-signin/panel/words.js +111 -0
- package/dist/player-signin/scheme-check.d.ts +3 -0
- package/dist/player-signin/scheme-check.js +14 -0
- package/dist/player-signin/scheme.d.ts +13 -0
- package/dist/player-signin/scheme.js +28 -0
- package/dist/player-signin/types.d.ts +116 -0
- package/dist/player-signin/types.js +33 -0
- package/dist/shop/adapters/d1.d.ts +25 -0
- package/dist/shop/adapters/d1.js +200 -0
- package/dist/shop/adapters/firestore.d.ts +53 -0
- package/dist/shop/adapters/firestore.js +245 -0
- package/dist/shop/address.d.ts +14 -0
- package/dist/shop/address.js +104 -0
- package/dist/shop/amount.d.ts +26 -0
- package/dist/shop/amount.js +59 -0
- package/dist/shop/conformance.d.ts +13 -0
- package/dist/shop/conformance.js +203 -0
- package/dist/shop/handler.d.ts +76 -0
- package/dist/shop/handler.js +156 -0
- package/dist/shop/index.d.ts +19 -0
- package/dist/shop/index.js +21 -0
- package/dist/shop/match.d.ts +23 -0
- package/dist/shop/match.js +58 -0
- package/dist/shop/memory.d.ts +13 -0
- package/dist/shop/memory.js +106 -0
- package/dist/shop/nodes.d.ts +47 -0
- package/dist/shop/nodes.js +106 -0
- package/dist/shop/orders.d.ts +47 -0
- package/dist/shop/orders.js +82 -0
- package/dist/shop/panel/index.d.ts +9 -0
- package/dist/shop/panel/index.js +14 -0
- package/dist/shop/panel/looks.d.ts +33 -0
- package/dist/shop/panel/looks.js +61 -0
- package/dist/shop/panel/mount.d.ts +68 -0
- package/dist/shop/panel/mount.js +221 -0
- package/dist/shop/panel/pay.d.ts +27 -0
- package/dist/shop/panel/pay.js +128 -0
- package/dist/shop/panel/qr.d.ts +9 -0
- package/dist/shop/panel/qr.js +281 -0
- package/dist/shop/panel/words.d.ts +89 -0
- package/dist/shop/panel/words.js +79 -0
- package/dist/shop/price.d.ts +23 -0
- package/dist/shop/price.js +43 -0
- package/dist/shop/scan.d.ts +24 -0
- package/dist/shop/scan.js +80 -0
- package/dist/shop/security.d.ts +10 -0
- package/dist/shop/security.js +56 -0
- package/dist/shop/store.d.ts +101 -0
- package/dist/shop/store.js +4 -0
- package/dist/shop/worker.d.ts +43 -0
- package/dist/shop/worker.js +76 -0
- package/package.json +21 -6
- package/src/admin/panel/mount.ts +7 -2
- package/src/player-signin/adapters/d1.sql +45 -0
- package/src/player-signin/adapters/d1.ts +120 -0
- package/src/player-signin/adapters/firestore.ts +194 -0
- package/src/player-signin/conformance.ts +184 -0
- package/src/player-signin/handler.ts +404 -0
- package/src/player-signin/host.ts +12 -0
- package/src/player-signin/index.ts +20 -0
- package/src/player-signin/memory.ts +94 -0
- package/src/player-signin/panel/index.ts +13 -0
- package/src/player-signin/panel/memo.ts +49 -0
- package/src/player-signin/panel/mount.ts +129 -0
- package/src/player-signin/panel/style.css +478 -0
- package/src/player-signin/panel/view.ts +433 -0
- package/src/player-signin/panel/words.ts +210 -0
- package/src/player-signin/scheme-check.ts +14 -0
- package/src/player-signin/scheme.ts +32 -0
- package/src/player-signin/types.ts +103 -0
- package/src/shop/adapters/d1.sql +52 -0
- package/src/shop/adapters/d1.ts +245 -0
- package/src/shop/adapters/firestore.ts +281 -0
- package/src/shop/address.ts +102 -0
- package/src/shop/amount.ts +66 -0
- package/src/shop/conformance.ts +232 -0
- package/src/shop/handler.ts +202 -0
- package/src/shop/index.ts +30 -0
- package/src/shop/match.ts +63 -0
- package/src/shop/memory.ts +102 -0
- package/src/shop/nodes.ts +115 -0
- package/src/shop/orders.ts +93 -0
- package/src/shop/panel/index.ts +19 -0
- package/src/shop/panel/looks.ts +106 -0
- package/src/shop/panel/mount.ts +280 -0
- package/src/shop/panel/pay.ts +192 -0
- package/src/shop/panel/qr.ts +256 -0
- package/src/shop/panel/style.css +538 -0
- package/src/shop/panel/words.ts +172 -0
- package/src/shop/price.ts +52 -0
- package/src/shop/scan.ts +90 -0
- package/src/shop/security.ts +50 -0
- package/src/shop/store.ts +99 -0
- package/src/shop/worker.ts +107 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// The player's sign-in, ready to put in a sheet: `signInView(options)` gives { node, stop, reset }. Plain DOM, no framework, no browser dialog.
|
|
2
|
+
//
|
|
3
|
+
// 1. The ways: "Use a passkey" first (Face ID, Touch ID or the device PIN), then the wallet apps as tiles. A browser that reports no passkey PRF
|
|
4
|
+
// shows the passkey last, with "Not on this browser".
|
|
5
|
+
// 2. The passkey: "Use my passkey" and "Make a passkey" (the main key is "Use" when this browser made or used one before). A new passkey is made,
|
|
6
|
+
// then used at once (its seed comes from a USE, never from the answer at create: @nano133/signin/passkey); where the browser allows one prompt
|
|
7
|
+
// per press, one key "Open my passkey" finishes it. A NEW account then shows "Keep a spare key" (it can be skipped, not missed). The purse signs
|
|
8
|
+
// the game's sign-in text in the page (@nano133/signin/purse, loaded only then) and the server checks it ({base}/passkey).
|
|
9
|
+
// 3. A wallet app: on a touch screen one key that opens the app; with a mouse its QR code (the game's `qr` hook) for the phone. The page asks every
|
|
10
|
+
// 2 seconds until the wallet has signed (5 minutes at most).
|
|
11
|
+
//
|
|
12
|
+
// The seed lives in local variables of one sign-in only: never in storage, a log, an error text or an address. The page remembers only the passkeys'
|
|
13
|
+
// ids and the last way (./memo.ts).
|
|
14
|
+
//
|
|
15
|
+
// `words` changes any word of the sheet (./words.ts: PLAYER_SIGNIN_WORDS are the defaults), for a game's own words or language.
|
|
16
|
+
import { firstUse, makePasskey, openPasskey, PasskeyError, passkeyFault, passkeySupport } from "@nano133/signin/passkey";
|
|
17
|
+
import { ApiError, createApi } from "../../admin/panel/api.js";
|
|
18
|
+
import { h, shortAddress } from "../../admin/panel/dom.js";
|
|
19
|
+
import { icon } from "../../admin/panel/icons.js";
|
|
20
|
+
import { checkScheme } from "../scheme-check.js";
|
|
21
|
+
import { previewHost } from "../host.js";
|
|
22
|
+
import { WAYS } from "../types.js";
|
|
23
|
+
import { readMemo, rememberLast, rememberMade, rememberOpened } from "./memo.js";
|
|
24
|
+
import { faultWords, playerSigninWords } from "./words.js";
|
|
25
|
+
/** The wallets, with letters, not logos: no wallet's licence clearly allows its logo. */
|
|
26
|
+
const WALLETS = {
|
|
27
|
+
berry: { name: "Berry Wallet", mark: "B" },
|
|
28
|
+
nanchat: { name: "NanChat", mark: "NC" },
|
|
29
|
+
nautilus: { name: "Nautilus", mark: "N" },
|
|
30
|
+
};
|
|
31
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
32
|
+
/** A key, the passkey's mark (drawn here: the shared icons have none). */
|
|
33
|
+
function keyIcon(size = 26) {
|
|
34
|
+
const svg = document.createElementNS(NS, "svg");
|
|
35
|
+
for (const [k, v] of Object.entries({ viewBox: "0 0 24 24", width: size, height: size, fill: "none", stroke: "currentColor", "stroke-width": 1.9, "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", focusable: "false" }))
|
|
36
|
+
svg.setAttribute(k, String(v));
|
|
37
|
+
for (const d of ["M14.5 9.5a4.5 4.5 0 1 1-1.3-3.2", "M13.2 6.3 21 6.3M18 6.3v3M21 6.3v2.2"]) {
|
|
38
|
+
const p = document.createElementNS(NS, "path");
|
|
39
|
+
p.setAttribute("d", d);
|
|
40
|
+
svg.append(p);
|
|
41
|
+
}
|
|
42
|
+
const c = document.createElementNS(NS, "circle");
|
|
43
|
+
for (const [k, v] of Object.entries({ cx: 10, cy: 9.5, r: 1.4, fill: "currentColor", stroke: "none" }))
|
|
44
|
+
c.setAttribute(k, String(v));
|
|
45
|
+
svg.append(c);
|
|
46
|
+
return svg;
|
|
47
|
+
}
|
|
48
|
+
/** True when the page's host may ask for a passkey of `rpId`: the same host, or a subdomain of it. An IP address never may. */
|
|
49
|
+
export function hostFits(hostname, rpId) {
|
|
50
|
+
if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":"))
|
|
51
|
+
return false;
|
|
52
|
+
return hostname === rpId || hostname.endsWith(`.${rpId}`);
|
|
53
|
+
}
|
|
54
|
+
/** The passkey's name in the device's list: the game and the day it was made (the account does not exist yet). */
|
|
55
|
+
export const passkeyName = (game, now = new Date()) => `${game} · ${now.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}`;
|
|
56
|
+
export function signInView(options) {
|
|
57
|
+
const base = options.base ?? "/api/signin";
|
|
58
|
+
const api = createApi("", options.fetch ?? ((...a) => fetch(...a)));
|
|
59
|
+
const ways = (options.ways ?? WAYS).filter((w) => WAYS.includes(w));
|
|
60
|
+
const rpId = options.rpId ?? location.hostname;
|
|
61
|
+
const passkeyOn = options.passkey ?? !previewHost(rpId);
|
|
62
|
+
const scheme = passkeyOn ? checkScheme(options.scheme) : null;
|
|
63
|
+
const touch = () => options.touch ?? (typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches);
|
|
64
|
+
const w = playerSigninWords(options.words);
|
|
65
|
+
let stopped = false;
|
|
66
|
+
let timer;
|
|
67
|
+
let prompt = null;
|
|
68
|
+
/** The wallet request that is waiting. Back keeps it: the player may have approved it already. */
|
|
69
|
+
let current = null;
|
|
70
|
+
let support = "unknown";
|
|
71
|
+
const status = h("p", { class: "n133s-status", role: "status", "aria-live": "polite" });
|
|
72
|
+
const check = h("div", { class: "n133s-check" });
|
|
73
|
+
check.hidden = true;
|
|
74
|
+
const area = h("div", { class: "n133s-area" });
|
|
75
|
+
const node = h("div", { class: "n133-signin n133s-view" }, area, check, status);
|
|
76
|
+
const say = (text) => (status.textContent = text);
|
|
77
|
+
const busy = (on) => {
|
|
78
|
+
for (const b of Array.from(area.querySelectorAll("button")))
|
|
79
|
+
b.disabled = on;
|
|
80
|
+
node.toggleAttribute("aria-busy", on);
|
|
81
|
+
};
|
|
82
|
+
const key = (text, onclick, kind = "plain") => h("button", { class: `n133s-btn n133s-${kind}`, type: "button", onclick }, text);
|
|
83
|
+
const backKey = () => h("button", { class: "n133s-back", type: "button", onclick: () => showWays() }, icon("back", 18), h("span", {}, w.back));
|
|
84
|
+
const title = (text) => h("h3", { class: "n133s-step", tabindex: "-1" }, text);
|
|
85
|
+
const focusFirst = () => (area.querySelector("h3, button:not([disabled])") ?? area).focus?.({ preventScroll: true });
|
|
86
|
+
const setup = () => ({ rpId, rpName: options.name, scheme: scheme, userName: passkeyName(options.name), exclude: readMemo(rpId).ids });
|
|
87
|
+
/** Puts these in the area (a null is left out). */
|
|
88
|
+
const fill = (...parts) => area.replaceChildren(...parts.filter((p) => p !== null));
|
|
89
|
+
const personToken = async () => (options.personCheck ? options.personCheck(check) : undefined);
|
|
90
|
+
function stopWaiting() {
|
|
91
|
+
if (timer)
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
timer = undefined;
|
|
94
|
+
prompt?.abort();
|
|
95
|
+
prompt = null;
|
|
96
|
+
}
|
|
97
|
+
function done(player) {
|
|
98
|
+
stopWaiting();
|
|
99
|
+
current = null;
|
|
100
|
+
area.replaceChildren(h("p", { class: "n133s-done" }, icon("check", 22), h("span", {}, w.signedInAs(shortAddress(player.address)))));
|
|
101
|
+
say("");
|
|
102
|
+
options.onSignedIn?.(player);
|
|
103
|
+
}
|
|
104
|
+
// ---- the ways ----
|
|
105
|
+
function passkeyTile() {
|
|
106
|
+
const off = support === "no";
|
|
107
|
+
const last = readMemo(rpId).last === "passkey";
|
|
108
|
+
return h("button", { class: `n133s-pk${off ? " is-off" : ""}`, type: "button", onclick: () => showPasskey() }, h("span", { class: "n133s-pk-mark" }, keyIcon()), h("span", { class: "n133s-pk-words" }, h("strong", {}, w.usePasskey), h("small", {}, off ? w.passkeyOff : w.passkeyHint)), last ? h("span", { class: "n133s-last" }, w.lastUsed) : null);
|
|
109
|
+
}
|
|
110
|
+
function walletTiles() {
|
|
111
|
+
const last = readMemo(rpId).last;
|
|
112
|
+
return h("div", { class: "n133s-tiles" }, ...ways.map((way) => h("button", { class: "n133s-tile", type: "button", onclick: () => choose(way) }, h("span", { class: "n133s-mark", "aria-hidden": "true" }, WALLETS[way].mark), h("span", { class: "n133s-name" }, WALLETS[way].name), last === way ? h("span", { class: "n133s-last" }, w.lastUsed) : null)));
|
|
113
|
+
}
|
|
114
|
+
function showWays() {
|
|
115
|
+
stopWaiting();
|
|
116
|
+
busy(false);
|
|
117
|
+
const pk = passkeyOn ? passkeyTile() : null;
|
|
118
|
+
const first = pk && support !== "no";
|
|
119
|
+
const walletsPart = ways.length ? [h("p", { class: "n133s-or" }, pk ? w.orWallet : w.chooseWallet), walletTiles()] : [];
|
|
120
|
+
fill(h("p", { class: "n133s-lead" }, w.lead), first ? pk : null, ...walletsPart, !first && pk ? h("p", { class: "n133s-or" }, w.orPasskey) : null, !first ? pk : null);
|
|
121
|
+
say("");
|
|
122
|
+
}
|
|
123
|
+
// ---- the passkey ----
|
|
124
|
+
function showFault(e) {
|
|
125
|
+
busy(false);
|
|
126
|
+
const f = passkeyFault(e);
|
|
127
|
+
const fw = faultWords(f.code, rpId, w);
|
|
128
|
+
if (fw.next === "stay")
|
|
129
|
+
return showPasskey(fw.text);
|
|
130
|
+
fill(backKey(), title(fw.title), h("p", { class: "n133s-text n133s-fault", "data-fault": f.code }, fw.text), ways.length ? h("p", { class: "n133s-or" }, w.faultWallets) : null, ways.length ? walletTiles() : null);
|
|
131
|
+
say("");
|
|
132
|
+
focusFirst();
|
|
133
|
+
}
|
|
134
|
+
function showPasskey(note) {
|
|
135
|
+
stopWaiting();
|
|
136
|
+
busy(false);
|
|
137
|
+
if (!scheme)
|
|
138
|
+
return showWays();
|
|
139
|
+
if (typeof window === "undefined" || !window.PublicKeyCredential)
|
|
140
|
+
return showFault({ name: "NotSupportedError" });
|
|
141
|
+
if (support === "no")
|
|
142
|
+
return showFault(new PasskeyError("no-prf"));
|
|
143
|
+
if (!hostFits(location.hostname, rpId))
|
|
144
|
+
return showFault({ name: "SecurityError" });
|
|
145
|
+
const known = readMemo(rpId).ids.length > 0;
|
|
146
|
+
const use = key(w.useMine, () => void usePasskey(), known ? "primary" : "plain");
|
|
147
|
+
const make = key(w.makeOne, () => void makeNew(), known ? "plain" : "primary");
|
|
148
|
+
area.replaceChildren(backKey(), title(w.passkeyTitle), h("p", { class: "n133s-text" }, w.passkeyText), h("div", { class: "n133s-keys" }, known ? use : make, known ? make : use));
|
|
149
|
+
say(note ?? "");
|
|
150
|
+
focusFirst();
|
|
151
|
+
}
|
|
152
|
+
async function usePasskey() {
|
|
153
|
+
busy(true);
|
|
154
|
+
say(w.waitingPasskey);
|
|
155
|
+
prompt = new AbortController();
|
|
156
|
+
try {
|
|
157
|
+
const p = await openPasskey(setup(), prompt.signal);
|
|
158
|
+
if (stopped)
|
|
159
|
+
return;
|
|
160
|
+
await finish(p.seed, p.credentialId, false);
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
if (!stopped)
|
|
164
|
+
showFault(e);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function makeNew() {
|
|
168
|
+
busy(true);
|
|
169
|
+
say(w.makingPasskey);
|
|
170
|
+
prompt = new AbortController();
|
|
171
|
+
let made;
|
|
172
|
+
try {
|
|
173
|
+
made = await makePasskey(setup(), prompt.signal);
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
if (!stopped)
|
|
177
|
+
showFault(e);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// Remembered now, before its first use: a closed sheet or a reload must not forget it.
|
|
181
|
+
rememberMade(rpId, made.credentialId);
|
|
182
|
+
if (stopped)
|
|
183
|
+
return;
|
|
184
|
+
await firstOpen(made, false);
|
|
185
|
+
}
|
|
186
|
+
/** The first use of a passkey just made. Where it gives nothing (a browser that allows one prompt per press, a closed sheet), one key finishes it. */
|
|
187
|
+
async function firstOpen(made, pick) {
|
|
188
|
+
busy(true);
|
|
189
|
+
say(w.openingPasskey);
|
|
190
|
+
prompt = new AbortController();
|
|
191
|
+
try {
|
|
192
|
+
const p = await firstUse(setup(), made, prompt.signal, pick);
|
|
193
|
+
if (stopped)
|
|
194
|
+
return;
|
|
195
|
+
await finish(p.seed, p.credentialId, true);
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
if (stopped)
|
|
199
|
+
return;
|
|
200
|
+
const f = passkeyFault(e);
|
|
201
|
+
if (f.code !== "cancelled" && f.code !== "failed")
|
|
202
|
+
return showFault(f);
|
|
203
|
+
busy(false);
|
|
204
|
+
area.replaceChildren(title(w.madeTitle), h("p", { class: "n133s-text" }, w.madeText), h("div", { class: "n133s-keys" }, key(w.openMine, () => void firstOpen(made, true), "primary")));
|
|
205
|
+
say("");
|
|
206
|
+
focusFirst();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** "Keep a spare key": the code shows only at the press, and the player goes on with Continue or Later. */
|
|
210
|
+
function spareKey(seed) {
|
|
211
|
+
return new Promise((resolve) => {
|
|
212
|
+
busy(false);
|
|
213
|
+
const code = h("p", { class: "n133s-code", translate: "no" });
|
|
214
|
+
const codeBox = h("div", { class: "n133s-codebox" }, code);
|
|
215
|
+
codeBox.hidden = true;
|
|
216
|
+
const go = key(w.later, () => resolve(), "quiet");
|
|
217
|
+
const copyBtn = key(w.copyCode, async () => {
|
|
218
|
+
try {
|
|
219
|
+
await navigator.clipboard.writeText(seed);
|
|
220
|
+
copyBtn.textContent = w.copied;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
copyBtn.textContent = w.copyFailed;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
codeBox.append(copyBtn);
|
|
227
|
+
const show = key(w.showCode, () => {
|
|
228
|
+
code.textContent = seed.match(/.{1,16}/g).join("\n");
|
|
229
|
+
codeBox.hidden = false;
|
|
230
|
+
show.remove();
|
|
231
|
+
go.textContent = w.continue;
|
|
232
|
+
go.className = "n133s-btn n133s-primary";
|
|
233
|
+
}, "primary");
|
|
234
|
+
area.replaceChildren(title(w.spareTitle), h("p", { class: "n133s-text" }, w.spareText), codeBox, h("p", { class: "n133s-fine" }, w.spareFine), h("div", { class: "n133s-keys" }, show, go));
|
|
235
|
+
say("");
|
|
236
|
+
focusFirst();
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async function finish(seed, credentialId, made) {
|
|
240
|
+
const fresh = made || readMemo(rpId).unopened.includes(credentialId);
|
|
241
|
+
if (fresh && options.spareKey !== false)
|
|
242
|
+
await spareKey(seed);
|
|
243
|
+
if (stopped)
|
|
244
|
+
return;
|
|
245
|
+
busy(true);
|
|
246
|
+
say(w.signingIn);
|
|
247
|
+
try {
|
|
248
|
+
const { signinBody } = await import("@nano133/signin/purse");
|
|
249
|
+
const turnstile = await personToken();
|
|
250
|
+
const r = await api.post(`${base}/passkey`, { ...signinBody(location.host, seed), ...(turnstile ? { turnstile } : {}) });
|
|
251
|
+
rememberOpened(rpId, credentialId);
|
|
252
|
+
if (!stopped)
|
|
253
|
+
done({ address: r.address, via: "passkey" });
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
if (!stopped)
|
|
257
|
+
showPasskey(e instanceof ApiError ? w.refusal(e.message, e.status) : w.signInFailed);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// ---- a wallet app ----
|
|
261
|
+
async function choose(way) {
|
|
262
|
+
if (current?.way === way && Date.now() < current.expiresAt)
|
|
263
|
+
return showWallet(current);
|
|
264
|
+
stopWaiting();
|
|
265
|
+
current = null;
|
|
266
|
+
busy(true);
|
|
267
|
+
say(w.starting);
|
|
268
|
+
try {
|
|
269
|
+
const turnstile = await personToken();
|
|
270
|
+
const s = await api.post(`${base}/start`, { way, ...(turnstile ? { turnstile } : {}) });
|
|
271
|
+
if (stopped)
|
|
272
|
+
return;
|
|
273
|
+
current = s;
|
|
274
|
+
rememberLast(rpId, way);
|
|
275
|
+
showWallet(s);
|
|
276
|
+
void poll(s);
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
busy(false);
|
|
280
|
+
showWays();
|
|
281
|
+
say(e instanceof ApiError ? w.refusal(e.message, e.status) : e instanceof Error && e.message ? w.refusal(e.message, 0) : w.startFailed);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function showWallet(s) {
|
|
285
|
+
const name = WALLETS[s.way].name;
|
|
286
|
+
area.replaceChildren(backKey(), title(name), ...walletSteps(s, name));
|
|
287
|
+
say(w.waitingFor(name));
|
|
288
|
+
focusFirst();
|
|
289
|
+
}
|
|
290
|
+
/** What the player does with the wallet: on a touch screen, one key; with a mouse, a QR code for the phone. */
|
|
291
|
+
function walletSteps(s, name) {
|
|
292
|
+
const open = (text, href, newTab = false) => h("a", { class: "n133s-btn n133s-primary", href, target: newTab ? "_blank" : undefined, rel: newTab ? "noopener" : undefined }, text);
|
|
293
|
+
const qr = (text) => {
|
|
294
|
+
if (!options.qr)
|
|
295
|
+
return h("p", { class: "n133s-fine" }, w.openOnPhone(name));
|
|
296
|
+
const c = options.qr(text);
|
|
297
|
+
c.setAttribute("aria-label", w.qrLabel(name));
|
|
298
|
+
return h("div", { class: "n133s-qr" }, c);
|
|
299
|
+
};
|
|
300
|
+
const only = h("p", { class: "n133s-fine" }, w.onlyYours(location.host));
|
|
301
|
+
const comeBack = h("p", { class: "n133s-fine" }, w.comeBack(s.way === "berry" ? w.yourWallet : name));
|
|
302
|
+
if (s.way === "nautilus") {
|
|
303
|
+
const code = h("p", { class: "n133s-text" }, w.checkCode, h("strong", { class: "n133s-signcode" }, s.code ?? ""));
|
|
304
|
+
return touch() ? [code, open(w.openApp(name), s.link), comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanWith(name)), code, only];
|
|
305
|
+
}
|
|
306
|
+
if (s.way === "nanchat") {
|
|
307
|
+
const here = h("p", { class: "n133s-fine" }, w.onComputer, h("a", { href: s.link, target: "_blank", rel: "noopener" }, w.openLink(name)));
|
|
308
|
+
return touch() ? [open(w.openApp(name), s.link, true), comeBack, only] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanCamera(name)), only, here];
|
|
309
|
+
}
|
|
310
|
+
const other = h("a", { class: "n133s-btn n133s-plain", href: s.walletLink ?? s.link }, w.anotherWallet);
|
|
311
|
+
const here = h("p", { class: "n133s-fine" }, w.onComputer, h("a", { href: s.walletLink ?? s.link }, w.openLink(w.aWallet)));
|
|
312
|
+
return touch() ? [open(w.openApp(name), s.link), other, comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanWith(name)), only, here];
|
|
313
|
+
}
|
|
314
|
+
/** The request ended without a sign-in: say why, and offer a new one. */
|
|
315
|
+
function again(s, words) {
|
|
316
|
+
current = null;
|
|
317
|
+
area.replaceChildren(backKey(), title(WALLETS[s.way].name), h("div", { class: "n133s-keys" }, key(w.tryAgain, () => void choose(s.way), "primary"), key(w.otherWay, () => showWays())));
|
|
318
|
+
say(words);
|
|
319
|
+
}
|
|
320
|
+
async function poll(s) {
|
|
321
|
+
if (stopped || current !== s)
|
|
322
|
+
return;
|
|
323
|
+
if (Date.now() > s.expiresAt + 5000)
|
|
324
|
+
return again(s, w.expired);
|
|
325
|
+
try {
|
|
326
|
+
const r = await api.post(`${base}/check`, { id: s.id, way: s.way });
|
|
327
|
+
if (stopped || current !== s)
|
|
328
|
+
return;
|
|
329
|
+
if (r.status === "signedin" && r.address)
|
|
330
|
+
return done({ address: r.address, via: "wallet" });
|
|
331
|
+
if (r.status === "elsewhere" || r.status === "used")
|
|
332
|
+
return again(s, w.usedElsewhere);
|
|
333
|
+
if (r.status === "expired")
|
|
334
|
+
return again(s, w.expired);
|
|
335
|
+
}
|
|
336
|
+
catch (e) {
|
|
337
|
+
if (e instanceof ApiError && e.status === 429)
|
|
338
|
+
say(w.tooManyChecks);
|
|
339
|
+
}
|
|
340
|
+
timer = setTimeout(() => void poll(s), 2000);
|
|
341
|
+
}
|
|
342
|
+
function reset() {
|
|
343
|
+
stopped = false;
|
|
344
|
+
current = null;
|
|
345
|
+
showWays();
|
|
346
|
+
}
|
|
347
|
+
showWays();
|
|
348
|
+
if (passkeyOn)
|
|
349
|
+
void passkeySupport().then((s) => {
|
|
350
|
+
support = s;
|
|
351
|
+
// Only the ways' screen depends on it: redraw it when it shows.
|
|
352
|
+
if (!stopped && area.querySelector(".n133s-pk"))
|
|
353
|
+
showWays();
|
|
354
|
+
});
|
|
355
|
+
return {
|
|
356
|
+
node,
|
|
357
|
+
stop() {
|
|
358
|
+
stopped = true;
|
|
359
|
+
stopWaiting();
|
|
360
|
+
},
|
|
361
|
+
reset,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { PasskeyFault } from "@nano133/signin/passkey";
|
|
2
|
+
/** What to do next after a fault: stay on the passkey step, or offer the wallet apps. */
|
|
3
|
+
export type FaultWords = {
|
|
4
|
+
title: string;
|
|
5
|
+
text: string;
|
|
6
|
+
next: "stay" | "wallets";
|
|
7
|
+
};
|
|
8
|
+
/** A fault's words: its title, and its text (a function when it names the game's domain, `rpId`). */
|
|
9
|
+
export type FaultText = {
|
|
10
|
+
title: string;
|
|
11
|
+
text: string | ((rpId: string) => string);
|
|
12
|
+
};
|
|
13
|
+
export type PlayerSigninWords = {
|
|
14
|
+
/** The sheet's title. The option `title` wins over it. */
|
|
15
|
+
title: string;
|
|
16
|
+
/** The close key's name for a screen reader. */
|
|
17
|
+
close: string;
|
|
18
|
+
/** The first line of the ways. */
|
|
19
|
+
lead: string;
|
|
20
|
+
usePasskey: string;
|
|
21
|
+
/** Under "Use a passkey". */
|
|
22
|
+
passkeyHint: string;
|
|
23
|
+
/** Under "Use a passkey" where the browser reports no passkey PRF. */
|
|
24
|
+
passkeyOff: string;
|
|
25
|
+
lastUsed: string;
|
|
26
|
+
/** Before the wallet tiles, after the passkey. */
|
|
27
|
+
orWallet: string;
|
|
28
|
+
/** Before the wallet tiles, with no passkey before them. */
|
|
29
|
+
chooseWallet: string;
|
|
30
|
+
/** Before the passkey, when it comes last. */
|
|
31
|
+
orPasskey: string;
|
|
32
|
+
back: string;
|
|
33
|
+
passkeyTitle: string;
|
|
34
|
+
passkeyText: string;
|
|
35
|
+
useMine: string;
|
|
36
|
+
makeOne: string;
|
|
37
|
+
waitingPasskey: string;
|
|
38
|
+
makingPasskey: string;
|
|
39
|
+
openingPasskey: string;
|
|
40
|
+
/** A passkey just made, whose first use gave nothing: one key opens it. */
|
|
41
|
+
madeTitle: string;
|
|
42
|
+
madeText: string;
|
|
43
|
+
openMine: string;
|
|
44
|
+
spareTitle: string;
|
|
45
|
+
spareText: string;
|
|
46
|
+
/** Under the code: keep it secret; how to use it. */
|
|
47
|
+
spareFine: string;
|
|
48
|
+
showCode: string;
|
|
49
|
+
copyCode: string;
|
|
50
|
+
copied: string;
|
|
51
|
+
copyFailed: string;
|
|
52
|
+
later: string;
|
|
53
|
+
continue: string;
|
|
54
|
+
signingIn: string;
|
|
55
|
+
signInFailed: string;
|
|
56
|
+
/** After a fault that offers the wallet apps. */
|
|
57
|
+
faultWallets: string;
|
|
58
|
+
faults: Record<PasskeyFault, FaultText>;
|
|
59
|
+
starting: string;
|
|
60
|
+
startFailed: string;
|
|
61
|
+
/** The words of a refusal: the server's own (`text`, in English) and its HTTP status (0: no answer). Default: the server's words. */
|
|
62
|
+
refusal: (text: string, status: number) => string;
|
|
63
|
+
/** The status while the page waits for a wallet ("Berry Wallet"). */
|
|
64
|
+
waitingFor: (wallet: string) => string;
|
|
65
|
+
/** After a sign-in, with the player's short address. */
|
|
66
|
+
signedInAs: (address: string) => string;
|
|
67
|
+
/** The key that opens a wallet app on a touch screen. */
|
|
68
|
+
openApp: (wallet: string) => string;
|
|
69
|
+
anotherWallet: string;
|
|
70
|
+
/** Before a link that opens the wallet on this computer. */
|
|
71
|
+
onComputer: string;
|
|
72
|
+
/** The link's words: open a wallet ("NanChat", or `aWallet`). */
|
|
73
|
+
openLink: (wallet: string) => string;
|
|
74
|
+
aWallet: string;
|
|
75
|
+
/** With no QR hook, on a screen with a mouse. */
|
|
76
|
+
openOnPhone: (wallet: string) => string;
|
|
77
|
+
qrLabel: (wallet: string) => string;
|
|
78
|
+
/** Only approve a sign-in the player started, on this host. */
|
|
79
|
+
onlyYours: (host: string) => string;
|
|
80
|
+
/** After the key that opens the app (`yourWallet` for Berry Wallet, which opens any wallet). */
|
|
81
|
+
comeBack: (wallet: string) => string;
|
|
82
|
+
yourWallet: string;
|
|
83
|
+
/** Under a QR code: scan it with this wallet. */
|
|
84
|
+
scanWith: (wallet: string) => string;
|
|
85
|
+
/** Under NanChat's QR code: the phone's camera opens it. */
|
|
86
|
+
scanCamera: (wallet: string) => string;
|
|
87
|
+
/** Before Nautilus's code (the code follows, bold). */
|
|
88
|
+
checkCode: string;
|
|
89
|
+
tryAgain: string;
|
|
90
|
+
otherWay: string;
|
|
91
|
+
expired: string;
|
|
92
|
+
usedElsewhere: string;
|
|
93
|
+
tooManyChecks: string;
|
|
94
|
+
};
|
|
95
|
+
/** `words` as a game gives it: any of the keys, and any of the faults. */
|
|
96
|
+
export type PlayerSigninWordsOption = Partial<Omit<PlayerSigninWords, "faults">> & {
|
|
97
|
+
faults?: Partial<Record<PasskeyFault, FaultText>>;
|
|
98
|
+
};
|
|
99
|
+
export declare const PLAYER_SIGNIN_WORDS: PlayerSigninWords;
|
|
100
|
+
/** The game's words over the defaults; its faults over the default faults. A key it leaves out, or gives as undefined, keeps its default. */
|
|
101
|
+
export declare function playerSigninWords(over?: PlayerSigninWordsOption): PlayerSigninWords;
|
|
102
|
+
/** A fault's title, text and next step, in the game's words (default: PLAYER_SIGNIN_WORDS). An unknown code gets the words of "failed". */
|
|
103
|
+
export declare function faultWords(code: PasskeyFault, rpId: string, words?: PlayerSigninWordsOption): FaultWords;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Every word of the sign-in sheet, and its words for each passkey fault, in plain English. A game gives `words` to `mountPlayerSignin` (or to
|
|
2
|
+
// `signInView`) with the keys it wants to change, in its own words or language; the rest stay these. A text with a value in it is a function of that
|
|
3
|
+
// value. The wallets' names (Berry Wallet, NanChat, Nautilus) are names, not words: they stay.
|
|
4
|
+
//
|
|
5
|
+
// The fault codes are @nano133/signin's (PasskeyFault). A fault never names a seed or a key, and never tells the player to delete a passkey. No
|
|
6
|
+
// sentence has more than 10 words.
|
|
7
|
+
export const PLAYER_SIGNIN_WORDS = {
|
|
8
|
+
title: "Sign in",
|
|
9
|
+
close: "Close",
|
|
10
|
+
lead: "No password and no payment.",
|
|
11
|
+
usePasskey: "Use a passkey",
|
|
12
|
+
passkeyHint: "Face ID, Touch ID or your device PIN",
|
|
13
|
+
passkeyOff: "Not on this browser",
|
|
14
|
+
lastUsed: "Last used",
|
|
15
|
+
orWallet: "Or a wallet app",
|
|
16
|
+
chooseWallet: "Choose your wallet",
|
|
17
|
+
orPasskey: "Or a passkey",
|
|
18
|
+
back: "Back",
|
|
19
|
+
passkeyTitle: "Passkey",
|
|
20
|
+
passkeyText: "Face ID, Touch ID or your device PIN. The passkey stays on your device. The game never sees it.",
|
|
21
|
+
useMine: "Use my passkey",
|
|
22
|
+
makeOne: "Make a passkey",
|
|
23
|
+
waitingPasskey: "Waiting for your passkey…",
|
|
24
|
+
makingPasskey: "Making your passkey…",
|
|
25
|
+
openingPasskey: "Opening your passkey…",
|
|
26
|
+
madeTitle: "Your passkey is made",
|
|
27
|
+
madeText: "Press the key to open it.",
|
|
28
|
+
openMine: "Open my passkey",
|
|
29
|
+
spareTitle: "Keep a spare key",
|
|
30
|
+
spareText: "Your looks belong to this passkey. If you lose it, this code brings them back.",
|
|
31
|
+
spareFine: "Keep it secret. Anyone with it can sign in as you. Import it as a seed in Nautilus or another wallet. Then sign in with that wallet.",
|
|
32
|
+
showCode: "Show my code",
|
|
33
|
+
copyCode: "Copy the code",
|
|
34
|
+
copied: "Copied",
|
|
35
|
+
copyFailed: "Copy failed: write it down",
|
|
36
|
+
later: "Later",
|
|
37
|
+
continue: "Continue",
|
|
38
|
+
signingIn: "Signing in…",
|
|
39
|
+
signInFailed: "Sign-in did not work. Try again.",
|
|
40
|
+
faultWallets: "Sign in with a wallet app",
|
|
41
|
+
faults: {
|
|
42
|
+
cancelled: { title: "No passkey was used", text: "No passkey was used." },
|
|
43
|
+
"no-prf": { title: "Not on this device yet", text: "This device's passkeys cannot sign in to games yet. Use a wallet app, or a passkey on another device." },
|
|
44
|
+
unsupported: { title: "No passkeys here", text: "This browser has no passkeys. Use a wallet app." },
|
|
45
|
+
"other-form": { title: "Not this password manager", text: "This game cannot read your password manager's answer. Choose iCloud Keychain or Google Password Manager instead. Or use a wallet app." },
|
|
46
|
+
"not-a-purse": { title: "Not this game's passkey", text: "That passkey is not one of this game's. Choose another one, or make one." },
|
|
47
|
+
"wrong-host": { title: "Wrong address", text: (rpId) => `Passkeys for this game work only on ${rpId}.` },
|
|
48
|
+
exists: { title: "You have a passkey", text: "This device has your passkey already. Use it." },
|
|
49
|
+
unstable: { title: "Not on this device", text: "This device gave two answers for one passkey. It cannot sign in here. Use a wallet app." },
|
|
50
|
+
"other-passkey": { title: "Another passkey", text: "That is another passkey. Choose the one you just made. Its name has today's date." },
|
|
51
|
+
failed: { title: "That did not work", text: "The passkey did not work. Try again." },
|
|
52
|
+
},
|
|
53
|
+
starting: "Starting…",
|
|
54
|
+
startFailed: "Sign-in did not start. Try again.",
|
|
55
|
+
refusal: (text) => text,
|
|
56
|
+
waitingFor: (wallet) => `Waiting for ${wallet}…`,
|
|
57
|
+
signedInAs: (address) => `Signed in as ${address}`,
|
|
58
|
+
openApp: (wallet) => `Open ${wallet}`,
|
|
59
|
+
anotherWallet: "Another wallet",
|
|
60
|
+
onComputer: "On this computer: ",
|
|
61
|
+
openLink: (wallet) => `open ${wallet}`,
|
|
62
|
+
aWallet: "a wallet",
|
|
63
|
+
openOnPhone: (wallet) => `Open this page on your phone to use ${wallet}.`,
|
|
64
|
+
qrLabel: (wallet) => `QR code to sign in with ${wallet}`,
|
|
65
|
+
onlyYours: (host) => `Only approve a sign-in you started yourself, on ${host}.`,
|
|
66
|
+
comeBack: (wallet) => `Approve the sign-in in ${wallet}. Then come back to this page.`,
|
|
67
|
+
yourWallet: "your wallet",
|
|
68
|
+
scanWith: (wallet) => `Scan this with ${wallet} on your phone. Then approve the sign-in.`,
|
|
69
|
+
scanCamera: (wallet) => `Scan this with your phone's camera to open ${wallet}. Then approve the sign-in.`,
|
|
70
|
+
checkCode: "Check that Nautilus shows this code: ",
|
|
71
|
+
tryAgain: "Try again",
|
|
72
|
+
otherWay: "Choose another way",
|
|
73
|
+
expired: "This sign-in expired.",
|
|
74
|
+
usedElsewhere: "This sign-in was used in another browser. Start again.",
|
|
75
|
+
tooManyChecks: "Too many checks. Waiting a moment…",
|
|
76
|
+
};
|
|
77
|
+
/** The game's words over the defaults; its faults over the default faults. A key it leaves out, or gives as undefined, keeps its default. */
|
|
78
|
+
export function playerSigninWords(over) {
|
|
79
|
+
const out = { ...PLAYER_SIGNIN_WORDS, faults: { ...PLAYER_SIGNIN_WORDS.faults } };
|
|
80
|
+
for (const [k, v] of Object.entries(over ?? {})) {
|
|
81
|
+
if (v === undefined || !(k in PLAYER_SIGNIN_WORDS))
|
|
82
|
+
continue;
|
|
83
|
+
if (k === "faults") {
|
|
84
|
+
for (const [code, f] of Object.entries(v))
|
|
85
|
+
if (f && code in out.faults)
|
|
86
|
+
out.faults[code] = f;
|
|
87
|
+
}
|
|
88
|
+
else
|
|
89
|
+
out[k] = v;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/** What happens after each fault: the passkey step again, or the wallet apps. Not words: the sheet's steps. */
|
|
94
|
+
const NEXT = {
|
|
95
|
+
cancelled: "stay",
|
|
96
|
+
"no-prf": "wallets",
|
|
97
|
+
unsupported: "wallets",
|
|
98
|
+
"other-form": "wallets",
|
|
99
|
+
"not-a-purse": "stay",
|
|
100
|
+
"wrong-host": "wallets",
|
|
101
|
+
exists: "stay",
|
|
102
|
+
unstable: "wallets",
|
|
103
|
+
"other-passkey": "stay",
|
|
104
|
+
failed: "stay",
|
|
105
|
+
};
|
|
106
|
+
/** A fault's title, text and next step, in the game's words (default: PLAYER_SIGNIN_WORDS). An unknown code gets the words of "failed". */
|
|
107
|
+
export function faultWords(code, rpId, words = PLAYER_SIGNIN_WORDS) {
|
|
108
|
+
const known = code in NEXT ? code : "failed";
|
|
109
|
+
const f = words.faults?.[known] ?? PLAYER_SIGNIN_WORDS.faults[known];
|
|
110
|
+
return { title: f.title, text: typeof f.text === "function" ? f.text(rpId) : f.text, next: NEXT[known] };
|
|
111
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The check of a passkey scheme's shape, with no package: the browser sheet and the server both use it (./scheme.ts says what a scheme is).
|
|
2
|
+
/** Throws with plain words when a scheme cannot be one: three texts of 1 to 200 characters, and a version byte from 1 to 255. */
|
|
3
|
+
export function checkScheme(scheme) {
|
|
4
|
+
if (!scheme || typeof scheme !== "object")
|
|
5
|
+
throw new Error("pass the game's frozen passkey scheme: { input, salt, info, versionByte }");
|
|
6
|
+
for (const k of ["input", "salt", "info"]) {
|
|
7
|
+
const v = scheme[k];
|
|
8
|
+
if (typeof v !== "string" || !v.trim() || v.length > 200)
|
|
9
|
+
throw new Error(`the scheme's ${k} must be a text of 1 to 200 characters`);
|
|
10
|
+
}
|
|
11
|
+
if (!Number.isInteger(scheme.versionByte) || scheme.versionByte < 1 || scheme.versionByte > 255)
|
|
12
|
+
throw new Error("the scheme's versionByte must be a whole number from 1 to 255");
|
|
13
|
+
return scheme;
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type PasskeyScheme } from "@nano133/signin/passkey";
|
|
2
|
+
import { checkScheme } from "./scheme-check.js";
|
|
3
|
+
export type { PasskeyScheme };
|
|
4
|
+
export { checkScheme };
|
|
5
|
+
/**
|
|
6
|
+
* One fixed vector of a scheme: the seed and the address that a PRF output gives. For the game's own test, which must never change:
|
|
7
|
+
*
|
|
8
|
+
* assert.deepEqual(await schemeVector(SCHEME, "0102…1f20"), { seed: "A3D5…", address: "nano_14kj…" });
|
|
9
|
+
*/
|
|
10
|
+
export declare function schemeVector(scheme: PasskeyScheme, prfHex: string): Promise<{
|
|
11
|
+
seed: string;
|
|
12
|
+
address: string;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The game's passkey SCHEME: the texts that turn a passkey's secret (its PRF output) into the player's Nano account. It is the game's own, and it is
|
|
2
|
+
// FROZEN: a player's looks belong to the address these exact texts give.
|
|
3
|
+
//
|
|
4
|
+
// PRF input = UTF-8 scheme.input
|
|
5
|
+
// seed = HKDF-SHA-256(ikm = the PRF output, salt = UTF-8 scheme.salt, info = UTF-8 scheme.info, 32 bytes)
|
|
6
|
+
// account = the seed as 64 hex characters, account 0
|
|
7
|
+
//
|
|
8
|
+
// Write it one time, in one file of the game, and test it with fixed vectors (`schemeVector`): a PRF output, its seed, its address. A change of one
|
|
9
|
+
// character gives every player another account (and so no looks). A new scheme is a new `versionByte` with new texts, never an edit. The kit offers
|
|
10
|
+
// no default on purpose: a default in a library could change under the game.
|
|
11
|
+
import { seedFromPrf } from "@nano133/signin/passkey";
|
|
12
|
+
import { purseAddress } from "@nano133/signin/purse";
|
|
13
|
+
import { checkScheme } from "./scheme-check.js";
|
|
14
|
+
export { checkScheme };
|
|
15
|
+
const bytesOf = (prfHex) => {
|
|
16
|
+
if (!/^[0-9a-fA-F]{64}$/.test(prfHex))
|
|
17
|
+
throw new Error("a PRF output is 64 hex characters (32 bytes)");
|
|
18
|
+
return Uint8Array.from(prfHex.match(/../g).map((b) => parseInt(b, 16)));
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* One fixed vector of a scheme: the seed and the address that a PRF output gives. For the game's own test, which must never change:
|
|
22
|
+
*
|
|
23
|
+
* assert.deepEqual(await schemeVector(SCHEME, "0102…1f20"), { seed: "A3D5…", address: "nano_14kj…" });
|
|
24
|
+
*/
|
|
25
|
+
export async function schemeVector(scheme, prfHex) {
|
|
26
|
+
const seed = await seedFromPrf(bytesOf(prfHex), checkScheme(scheme));
|
|
27
|
+
return { seed, address: purseAddress(seed) };
|
|
28
|
+
}
|