@nano133/nanoterra 0.8.1 → 0.9.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/CHANGELOG.md +52 -0
- package/README.md +342 -6
- package/ai/SPEC.md +65 -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 +7 -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 +105 -0
- package/dist/player-signin/panel/view.d.ts +45 -0
- package/dist/player-signin/panel/view.js +360 -0
- package/dist/player-signin/panel/words.d.ts +8 -0
- package/dist/player-signin/panel/words.js +26 -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 +7 -0
- package/dist/shop/panel/index.js +11 -0
- package/dist/shop/panel/looks.d.ts +30 -0
- package/dist/shop/panel/looks.js +61 -0
- package/dist/shop/panel/mount.d.ts +52 -0
- package/dist/shop/panel/mount.js +194 -0
- package/dist/shop/panel/pay.d.ts +24 -0
- package/dist/shop/panel/pay.js +115 -0
- package/dist/shop/panel/qr.d.ts +9 -0
- package/dist/shop/panel/qr.js +281 -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 +12 -0
- package/src/player-signin/panel/memo.ts +49 -0
- package/src/player-signin/panel/mount.ts +127 -0
- package/src/player-signin/panel/style.css +473 -0
- package/src/player-signin/panel/view.ts +428 -0
- package/src/player-signin/panel/words.ts +32 -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 +15 -0
- package/src/shop/panel/looks.ts +106 -0
- package/src/shop/panel/mount.ts +241 -0
- package/src/shop/panel/pay.ts +171 -0
- package/src/shop/panel/qr.ts +256 -0
- package/src/shop/panel/style.css +534 -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,105 @@
|
|
|
1
|
+
// The sign-in sheet of a game: `mountPlayerSignin(element, options)`. It draws no key of its own: the game's account key, the shop's "sign in
|
|
2
|
+
// first" and anything else call `open()`, which resolves with the player once they are signed in, or null when the sheet closes. Plain DOM; a
|
|
3
|
+
// modal sheet with a scrim: from the bottom on a phone held upright, in the middle on a wide or sideways screen. Escape, the close key and a press
|
|
4
|
+
// on the scrim close it. Every key is 44 px or more.
|
|
5
|
+
import { h, releaseFocus } from "../../admin/panel/dom.js";
|
|
6
|
+
import { icon } from "../../admin/panel/icons.js";
|
|
7
|
+
import { signInView } from "./view.js";
|
|
8
|
+
let mounts = 0;
|
|
9
|
+
export function mountPlayerSignin(root, options) {
|
|
10
|
+
const n = ++mounts;
|
|
11
|
+
let isOpen = false;
|
|
12
|
+
let pending = null;
|
|
13
|
+
let before = null;
|
|
14
|
+
let closing;
|
|
15
|
+
const settle = (p) => {
|
|
16
|
+
const was = pending;
|
|
17
|
+
pending = null;
|
|
18
|
+
was?.resolve(p);
|
|
19
|
+
};
|
|
20
|
+
const view = signInView({
|
|
21
|
+
...options,
|
|
22
|
+
onSignedIn(player) {
|
|
23
|
+
options.onSignedIn?.(player);
|
|
24
|
+
settle(player);
|
|
25
|
+
closing = setTimeout(close, Math.max(0, options.closeAfterMs ?? 900));
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const titleEl = h("h2", { class: "n133s-title", id: `n133s-title-${n}` }, options.title ?? "Sign in");
|
|
29
|
+
const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label": "Close" }, icon("close", 20));
|
|
30
|
+
const scrim = h("div", { class: "n133s-scrim" });
|
|
31
|
+
const panel = h("section", { class: "n133s-panel", role: "dialog", "aria-modal": "true", "aria-labelledby": `n133s-title-${n}` }, h("header", { class: "n133s-head" }, titleEl, closeKey), h("div", { class: "n133s-body" }, view.node));
|
|
32
|
+
const wrap = h("div", { class: "n133-signin n133s-sheet", "data-open": "false" }, scrim, panel);
|
|
33
|
+
wrap.hidden = true;
|
|
34
|
+
root.append(wrap);
|
|
35
|
+
function close() {
|
|
36
|
+
if (closing)
|
|
37
|
+
clearTimeout(closing);
|
|
38
|
+
closing = undefined;
|
|
39
|
+
if (!isOpen)
|
|
40
|
+
return;
|
|
41
|
+
isOpen = false;
|
|
42
|
+
view.stop();
|
|
43
|
+
wrap.dataset.open = "false";
|
|
44
|
+
wrap.hidden = true;
|
|
45
|
+
settle(null);
|
|
46
|
+
if (options.focusAfterClose === "back" && before instanceof HTMLElement)
|
|
47
|
+
before.focus();
|
|
48
|
+
else
|
|
49
|
+
releaseFocus(wrap);
|
|
50
|
+
}
|
|
51
|
+
function open() {
|
|
52
|
+
if (isOpen && pending)
|
|
53
|
+
return pending.promise;
|
|
54
|
+
if (closing)
|
|
55
|
+
clearTimeout(closing);
|
|
56
|
+
let resolve;
|
|
57
|
+
const promise = new Promise((r) => (resolve = r));
|
|
58
|
+
pending = { promise, resolve };
|
|
59
|
+
before = document.activeElement;
|
|
60
|
+
isOpen = true;
|
|
61
|
+
view.reset();
|
|
62
|
+
wrap.hidden = false;
|
|
63
|
+
wrap.dataset.open = "true";
|
|
64
|
+
(panel.querySelector(".n133s-area button") ?? closeKey).focus({ preventScroll: true });
|
|
65
|
+
return promise;
|
|
66
|
+
}
|
|
67
|
+
closeKey.addEventListener("click", close);
|
|
68
|
+
scrim.addEventListener("click", close);
|
|
69
|
+
wrap.addEventListener("keydown", (e) => {
|
|
70
|
+
if (e.key === "Escape") {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
e.stopPropagation();
|
|
73
|
+
close();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (e.key !== "Tab")
|
|
77
|
+
return;
|
|
78
|
+
// The focus stays in the sheet while it is open.
|
|
79
|
+
const keys = Array.from(panel.querySelectorAll("button:not([disabled]), a[href], [tabindex]:not([tabindex='-1'])")).filter((el) => !el.closest("[hidden]"));
|
|
80
|
+
if (!keys.length)
|
|
81
|
+
return;
|
|
82
|
+
const first = keys[0];
|
|
83
|
+
const last = keys[keys.length - 1];
|
|
84
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
last.focus();
|
|
87
|
+
}
|
|
88
|
+
else if (!e.shiftKey && document.activeElement === last) {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
first.focus();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
open,
|
|
95
|
+
close,
|
|
96
|
+
get isOpen() {
|
|
97
|
+
return isOpen;
|
|
98
|
+
},
|
|
99
|
+
destroy() {
|
|
100
|
+
close();
|
|
101
|
+
view.stop();
|
|
102
|
+
wrap.remove();
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type PasskeyScheme } from "@nano133/signin/passkey";
|
|
2
|
+
import { type Way } from "../types.js";
|
|
3
|
+
export type SignedIn = {
|
|
4
|
+
address: string;
|
|
5
|
+
via: "wallet" | "passkey";
|
|
6
|
+
};
|
|
7
|
+
export type PlayerSigninViewOptions = {
|
|
8
|
+
/** The game's name: the device shows it next to the passkey ("Coin Bees · 1 Nov 2026"). */
|
|
9
|
+
name: string;
|
|
10
|
+
/** The game's FROZEN passkey scheme (see "/player-signin": checkScheme, schemeVector). Needed while the passkey is offered. */
|
|
11
|
+
scheme?: PasskeyScheme;
|
|
12
|
+
/** Offer the passkey. Default: on, except on a preview address (`*.workers.dev`, see `previewHost`): a passkey made there is lost when the game
|
|
13
|
+
* moves to its own domain. `true` offers it there too. */
|
|
14
|
+
passkey?: boolean;
|
|
15
|
+
/** The passkeys' relying party id: the game's domain. Default: the page's host name. The passkey is offered on this host and its subdomains only. */
|
|
16
|
+
rpId?: string;
|
|
17
|
+
/** The wallet apps, in the sheet's order. Default: Berry Wallet, NanChat, Nautilus. */
|
|
18
|
+
ways?: readonly Way[];
|
|
19
|
+
/** Where the handler's routes are. Default "/api/signin". */
|
|
20
|
+
base?: string;
|
|
21
|
+
/** Show "Keep a spare key" after a new passkey. Default true. */
|
|
22
|
+
spareKey?: boolean;
|
|
23
|
+
/** The person check (Turnstile): fills `box` and gives its token, before a wallet start and a passkey sign-in. No hook: no check. */
|
|
24
|
+
personCheck?: (box: HTMLElement) => Promise<string | null | undefined>;
|
|
25
|
+
/** Draws a QR code of `text` (a wallet's link), for a screen with a mouse. No hook: the link is shown as a link. */
|
|
26
|
+
qr?: (text: string) => HTMLElement;
|
|
27
|
+
/** The player signed in. */
|
|
28
|
+
onSignedIn?: (player: SignedIn) => void;
|
|
29
|
+
/** The `fetch` to use (default: the page's). */
|
|
30
|
+
fetch?: typeof fetch;
|
|
31
|
+
/** A touch screen (one key that opens the app) or not (a QR code). Default: `(pointer: coarse)`. */
|
|
32
|
+
touch?: boolean;
|
|
33
|
+
};
|
|
34
|
+
export type PlayerSigninView = {
|
|
35
|
+
node: HTMLElement;
|
|
36
|
+
/** Ends the waiting and any device prompt (the sheet closed). */
|
|
37
|
+
stop(): void;
|
|
38
|
+
/** Starts again at the ways (the sheet opens again). */
|
|
39
|
+
reset(): void;
|
|
40
|
+
};
|
|
41
|
+
/** 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. */
|
|
42
|
+
export declare function hostFits(hostname: string, rpId: string): boolean;
|
|
43
|
+
/** The passkey's name in the device's list: the game and the day it was made (the account does not exist yet). */
|
|
44
|
+
export declare const passkeyName: (game: string, now?: Date) => string;
|
|
45
|
+
export declare function signInView(options: PlayerSigninViewOptions): PlayerSigninView;
|
|
@@ -0,0 +1,360 @@
|
|
|
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
|
+
import { firstUse, makePasskey, openPasskey, PasskeyError, passkeyFault, passkeySupport } from "@nano133/signin/passkey";
|
|
15
|
+
import { ApiError, createApi } from "../../admin/panel/api.js";
|
|
16
|
+
import { h, shortAddress } from "../../admin/panel/dom.js";
|
|
17
|
+
import { icon } from "../../admin/panel/icons.js";
|
|
18
|
+
import { checkScheme } from "../scheme-check.js";
|
|
19
|
+
import { previewHost } from "../host.js";
|
|
20
|
+
import { WAYS } from "../types.js";
|
|
21
|
+
import { readMemo, rememberLast, rememberMade, rememberOpened } from "./memo.js";
|
|
22
|
+
import { faultWords } from "./words.js";
|
|
23
|
+
/** The wallets, with letters, not logos: no wallet's licence clearly allows its logo. */
|
|
24
|
+
const WALLETS = {
|
|
25
|
+
berry: { name: "Berry Wallet", mark: "B" },
|
|
26
|
+
nanchat: { name: "NanChat", mark: "NC" },
|
|
27
|
+
nautilus: { name: "Nautilus", mark: "N" },
|
|
28
|
+
};
|
|
29
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
30
|
+
/** A key, the passkey's mark (drawn here: the shared icons have none). */
|
|
31
|
+
function keyIcon(size = 26) {
|
|
32
|
+
const svg = document.createElementNS(NS, "svg");
|
|
33
|
+
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" }))
|
|
34
|
+
svg.setAttribute(k, String(v));
|
|
35
|
+
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"]) {
|
|
36
|
+
const p = document.createElementNS(NS, "path");
|
|
37
|
+
p.setAttribute("d", d);
|
|
38
|
+
svg.append(p);
|
|
39
|
+
}
|
|
40
|
+
const c = document.createElementNS(NS, "circle");
|
|
41
|
+
for (const [k, v] of Object.entries({ cx: 10, cy: 9.5, r: 1.4, fill: "currentColor", stroke: "none" }))
|
|
42
|
+
c.setAttribute(k, String(v));
|
|
43
|
+
svg.append(c);
|
|
44
|
+
return svg;
|
|
45
|
+
}
|
|
46
|
+
/** 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. */
|
|
47
|
+
export function hostFits(hostname, rpId) {
|
|
48
|
+
if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":"))
|
|
49
|
+
return false;
|
|
50
|
+
return hostname === rpId || hostname.endsWith(`.${rpId}`);
|
|
51
|
+
}
|
|
52
|
+
/** The passkey's name in the device's list: the game and the day it was made (the account does not exist yet). */
|
|
53
|
+
export const passkeyName = (game, now = new Date()) => `${game} · ${now.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}`;
|
|
54
|
+
export function signInView(options) {
|
|
55
|
+
const base = options.base ?? "/api/signin";
|
|
56
|
+
const api = createApi("", options.fetch ?? ((...a) => fetch(...a)));
|
|
57
|
+
const ways = (options.ways ?? WAYS).filter((w) => WAYS.includes(w));
|
|
58
|
+
const rpId = options.rpId ?? location.hostname;
|
|
59
|
+
const passkeyOn = options.passkey ?? !previewHost(rpId);
|
|
60
|
+
const scheme = passkeyOn ? checkScheme(options.scheme) : null;
|
|
61
|
+
const touch = () => options.touch ?? (typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches);
|
|
62
|
+
let stopped = false;
|
|
63
|
+
let timer;
|
|
64
|
+
let prompt = null;
|
|
65
|
+
/** The wallet request that is waiting. Back keeps it: the player may have approved it already. */
|
|
66
|
+
let current = null;
|
|
67
|
+
let support = "unknown";
|
|
68
|
+
const status = h("p", { class: "n133s-status", role: "status", "aria-live": "polite" });
|
|
69
|
+
const check = h("div", { class: "n133s-check" });
|
|
70
|
+
check.hidden = true;
|
|
71
|
+
const area = h("div", { class: "n133s-area" });
|
|
72
|
+
const node = h("div", { class: "n133-signin n133s-view" }, area, check, status);
|
|
73
|
+
const say = (text) => (status.textContent = text);
|
|
74
|
+
const busy = (on) => {
|
|
75
|
+
for (const b of Array.from(area.querySelectorAll("button")))
|
|
76
|
+
b.disabled = on;
|
|
77
|
+
node.toggleAttribute("aria-busy", on);
|
|
78
|
+
};
|
|
79
|
+
const key = (text, onclick, kind = "plain") => h("button", { class: `n133s-btn n133s-${kind}`, type: "button", onclick }, text);
|
|
80
|
+
const backKey = () => h("button", { class: "n133s-back", type: "button", onclick: () => showWays() }, icon("back", 18), h("span", {}, "Back"));
|
|
81
|
+
const title = (text) => h("h3", { class: "n133s-step", tabindex: "-1" }, text);
|
|
82
|
+
const focusFirst = () => (area.querySelector("h3, button:not([disabled])") ?? area).focus?.({ preventScroll: true });
|
|
83
|
+
const setup = () => ({ rpId, rpName: options.name, scheme: scheme, userName: passkeyName(options.name), exclude: readMemo(rpId).ids });
|
|
84
|
+
/** Puts these in the area (a null is left out). */
|
|
85
|
+
const fill = (...parts) => area.replaceChildren(...parts.filter((p) => p !== null));
|
|
86
|
+
const personToken = async () => (options.personCheck ? options.personCheck(check) : undefined);
|
|
87
|
+
function stopWaiting() {
|
|
88
|
+
if (timer)
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
timer = undefined;
|
|
91
|
+
prompt?.abort();
|
|
92
|
+
prompt = null;
|
|
93
|
+
}
|
|
94
|
+
function done(player) {
|
|
95
|
+
stopWaiting();
|
|
96
|
+
current = null;
|
|
97
|
+
area.replaceChildren(h("p", { class: "n133s-done" }, icon("check", 22), h("span", {}, `Signed in as ${shortAddress(player.address)}`)));
|
|
98
|
+
say("");
|
|
99
|
+
options.onSignedIn?.(player);
|
|
100
|
+
}
|
|
101
|
+
// ---- the ways ----
|
|
102
|
+
function passkeyTile() {
|
|
103
|
+
const off = support === "no";
|
|
104
|
+
const last = readMemo(rpId).last === "passkey";
|
|
105
|
+
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", {}, "Use a passkey"), h("small", {}, off ? "Not on this browser" : "Face ID, Touch ID or your device PIN")), last ? h("span", { class: "n133s-last" }, "Last used") : null);
|
|
106
|
+
}
|
|
107
|
+
function walletTiles() {
|
|
108
|
+
const last = readMemo(rpId).last;
|
|
109
|
+
return h("div", { class: "n133s-tiles" }, ...ways.map((w) => h("button", { class: "n133s-tile", type: "button", onclick: () => choose(w) }, h("span", { class: "n133s-mark", "aria-hidden": "true" }, WALLETS[w].mark), h("span", { class: "n133s-name" }, WALLETS[w].name), last === w ? h("span", { class: "n133s-last" }, "Last used") : null)));
|
|
110
|
+
}
|
|
111
|
+
function showWays() {
|
|
112
|
+
stopWaiting();
|
|
113
|
+
busy(false);
|
|
114
|
+
const pk = passkeyOn ? passkeyTile() : null;
|
|
115
|
+
const first = pk && support !== "no";
|
|
116
|
+
const walletsPart = ways.length ? [h("p", { class: "n133s-or" }, pk ? "Or a wallet app" : "Choose your wallet"), walletTiles()] : [];
|
|
117
|
+
fill(h("p", { class: "n133s-lead" }, "No password and no payment."), first ? pk : null, ...walletsPart, !first && pk ? h("p", { class: "n133s-or" }, "Or a passkey") : null, !first ? pk : null);
|
|
118
|
+
say("");
|
|
119
|
+
}
|
|
120
|
+
// ---- the passkey ----
|
|
121
|
+
function showFault(e) {
|
|
122
|
+
busy(false);
|
|
123
|
+
const f = passkeyFault(e);
|
|
124
|
+
const w = faultWords(f.code, rpId);
|
|
125
|
+
if (w.next === "stay")
|
|
126
|
+
return showPasskey(w.text);
|
|
127
|
+
fill(backKey(), title(w.title), h("p", { class: "n133s-text n133s-fault", "data-fault": f.code }, w.text), ways.length ? h("p", { class: "n133s-or" }, "Sign in with a wallet app") : null, ways.length ? walletTiles() : null);
|
|
128
|
+
say("");
|
|
129
|
+
focusFirst();
|
|
130
|
+
}
|
|
131
|
+
function showPasskey(note) {
|
|
132
|
+
stopWaiting();
|
|
133
|
+
busy(false);
|
|
134
|
+
if (!scheme)
|
|
135
|
+
return showWays();
|
|
136
|
+
if (typeof window === "undefined" || !window.PublicKeyCredential)
|
|
137
|
+
return showFault({ name: "NotSupportedError" });
|
|
138
|
+
if (support === "no")
|
|
139
|
+
return showFault(new PasskeyError("no-prf"));
|
|
140
|
+
if (!hostFits(location.hostname, rpId))
|
|
141
|
+
return showFault({ name: "SecurityError" });
|
|
142
|
+
const known = readMemo(rpId).ids.length > 0;
|
|
143
|
+
const use = key("Use my passkey", () => void usePasskey(), known ? "primary" : "plain");
|
|
144
|
+
const make = key("Make a passkey", () => void makeNew(), known ? "plain" : "primary");
|
|
145
|
+
area.replaceChildren(backKey(), title("Passkey"), h("p", { class: "n133s-text" }, "Face ID, Touch ID or your device PIN. The passkey stays on your device: the game never sees it."), h("div", { class: "n133s-keys" }, known ? use : make, known ? make : use));
|
|
146
|
+
say(note ?? "");
|
|
147
|
+
focusFirst();
|
|
148
|
+
}
|
|
149
|
+
async function usePasskey() {
|
|
150
|
+
busy(true);
|
|
151
|
+
say("Waiting for your passkey…");
|
|
152
|
+
prompt = new AbortController();
|
|
153
|
+
try {
|
|
154
|
+
const p = await openPasskey(setup(), prompt.signal);
|
|
155
|
+
if (stopped)
|
|
156
|
+
return;
|
|
157
|
+
await finish(p.seed, p.credentialId, false);
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
if (!stopped)
|
|
161
|
+
showFault(e);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function makeNew() {
|
|
165
|
+
busy(true);
|
|
166
|
+
say("Making your passkey…");
|
|
167
|
+
prompt = new AbortController();
|
|
168
|
+
let made;
|
|
169
|
+
try {
|
|
170
|
+
made = await makePasskey(setup(), prompt.signal);
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
if (!stopped)
|
|
174
|
+
showFault(e);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// Remembered now, before its first use: a closed sheet or a reload must not forget it.
|
|
178
|
+
rememberMade(rpId, made.credentialId);
|
|
179
|
+
if (stopped)
|
|
180
|
+
return;
|
|
181
|
+
await firstOpen(made, false);
|
|
182
|
+
}
|
|
183
|
+
/** 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. */
|
|
184
|
+
async function firstOpen(made, pick) {
|
|
185
|
+
busy(true);
|
|
186
|
+
say("Opening your passkey…");
|
|
187
|
+
prompt = new AbortController();
|
|
188
|
+
try {
|
|
189
|
+
const p = await firstUse(setup(), made, prompt.signal, pick);
|
|
190
|
+
if (stopped)
|
|
191
|
+
return;
|
|
192
|
+
await finish(p.seed, p.credentialId, true);
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
if (stopped)
|
|
196
|
+
return;
|
|
197
|
+
const f = passkeyFault(e);
|
|
198
|
+
if (f.code !== "cancelled" && f.code !== "failed")
|
|
199
|
+
return showFault(f);
|
|
200
|
+
busy(false);
|
|
201
|
+
area.replaceChildren(title("Your passkey is made"), h("p", { class: "n133s-text" }, "Press the key to open it."), h("div", { class: "n133s-keys" }, key("Open my passkey", () => void firstOpen(made, true), "primary")));
|
|
202
|
+
say("");
|
|
203
|
+
focusFirst();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** "Keep a spare key": the code shows only at the press, and the player goes on with Continue or Later. */
|
|
207
|
+
function spareKey(seed) {
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
busy(false);
|
|
210
|
+
const code = h("p", { class: "n133s-code", translate: "no" });
|
|
211
|
+
const codeBox = h("div", { class: "n133s-codebox" }, code);
|
|
212
|
+
codeBox.hidden = true;
|
|
213
|
+
const go = key("Later", () => resolve(), "quiet");
|
|
214
|
+
const copyBtn = key("Copy the code", async () => {
|
|
215
|
+
try {
|
|
216
|
+
await navigator.clipboard.writeText(seed);
|
|
217
|
+
copyBtn.textContent = "Copied";
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
copyBtn.textContent = "Copy failed: write it down";
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
codeBox.append(copyBtn);
|
|
224
|
+
const show = key("Show my code", () => {
|
|
225
|
+
code.textContent = seed.match(/.{1,16}/g).join("\n");
|
|
226
|
+
codeBox.hidden = false;
|
|
227
|
+
show.remove();
|
|
228
|
+
go.textContent = "Continue";
|
|
229
|
+
go.className = "n133s-btn n133s-primary";
|
|
230
|
+
}, "primary");
|
|
231
|
+
area.replaceChildren(title("Keep a spare key"), h("p", { class: "n133s-text" }, "Your looks belong to this passkey. If you lose it, this code brings them back."), codeBox, h("p", { class: "n133s-fine" }, "Keep it secret: whoever has it can sign in as you. To use it, import it as a seed in a Nano wallet such as Nautilus, then sign in with that wallet."), h("div", { class: "n133s-keys" }, show, go));
|
|
232
|
+
say("");
|
|
233
|
+
focusFirst();
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
async function finish(seed, credentialId, made) {
|
|
237
|
+
const fresh = made || readMemo(rpId).unopened.includes(credentialId);
|
|
238
|
+
if (fresh && options.spareKey !== false)
|
|
239
|
+
await spareKey(seed);
|
|
240
|
+
if (stopped)
|
|
241
|
+
return;
|
|
242
|
+
busy(true);
|
|
243
|
+
say("Signing in…");
|
|
244
|
+
try {
|
|
245
|
+
const { signinBody } = await import("@nano133/signin/purse");
|
|
246
|
+
const turnstile = await personToken();
|
|
247
|
+
const r = await api.post(`${base}/passkey`, { ...signinBody(location.host, seed), ...(turnstile ? { turnstile } : {}) });
|
|
248
|
+
rememberOpened(rpId, credentialId);
|
|
249
|
+
if (!stopped)
|
|
250
|
+
done({ address: r.address, via: "passkey" });
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
if (!stopped)
|
|
254
|
+
showPasskey(e instanceof ApiError ? e.message : "Sign-in did not work. Try again.");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// ---- a wallet app ----
|
|
258
|
+
async function choose(way) {
|
|
259
|
+
if (current?.way === way && Date.now() < current.expiresAt)
|
|
260
|
+
return showWallet(current);
|
|
261
|
+
stopWaiting();
|
|
262
|
+
current = null;
|
|
263
|
+
busy(true);
|
|
264
|
+
say("Starting…");
|
|
265
|
+
try {
|
|
266
|
+
const turnstile = await personToken();
|
|
267
|
+
const s = await api.post(`${base}/start`, { way, ...(turnstile ? { turnstile } : {}) });
|
|
268
|
+
if (stopped)
|
|
269
|
+
return;
|
|
270
|
+
current = s;
|
|
271
|
+
rememberLast(rpId, way);
|
|
272
|
+
showWallet(s);
|
|
273
|
+
void poll(s);
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
busy(false);
|
|
277
|
+
showWays();
|
|
278
|
+
say(e instanceof Error ? e.message : "Sign-in did not start. Try again.");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function showWallet(s) {
|
|
282
|
+
const name = WALLETS[s.way].name;
|
|
283
|
+
area.replaceChildren(backKey(), title(name), ...walletSteps(s, name));
|
|
284
|
+
say(`Waiting for ${name}…`);
|
|
285
|
+
focusFirst();
|
|
286
|
+
}
|
|
287
|
+
/** What the player does with the wallet: on a touch screen, one key; with a mouse, a QR code for the phone. */
|
|
288
|
+
function walletSteps(s, name) {
|
|
289
|
+
const open = (text, href, newTab = false) => h("a", { class: "n133s-btn n133s-primary", href, target: newTab ? "_blank" : undefined, rel: newTab ? "noopener" : undefined }, text);
|
|
290
|
+
const qr = (text) => {
|
|
291
|
+
if (!options.qr)
|
|
292
|
+
return h("p", { class: "n133s-fine" }, "Open this page on your phone to sign in with ", name, ".");
|
|
293
|
+
const c = options.qr(text);
|
|
294
|
+
c.setAttribute("aria-label", `QR code to sign in with ${name}`);
|
|
295
|
+
return h("div", { class: "n133s-qr" }, c);
|
|
296
|
+
};
|
|
297
|
+
const only = h("p", { class: "n133s-fine" }, `Only approve a sign-in that you started yourself, on ${location.host}.`);
|
|
298
|
+
const comeBack = h("p", { class: "n133s-fine" }, `Approve the sign-in in ${s.way === "berry" ? "your wallet" : name}, then come back to this page.`);
|
|
299
|
+
if (s.way === "nautilus") {
|
|
300
|
+
const code = h("p", { class: "n133s-text" }, "Check that Nautilus shows the code ", h("strong", { class: "n133s-signcode" }, s.code ?? ""), ".");
|
|
301
|
+
return touch() ? [code, open("Open Nautilus", s.link), comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, "Scan this with Nautilus on your phone, then approve the sign-in."), code, only];
|
|
302
|
+
}
|
|
303
|
+
if (s.way === "nanchat") {
|
|
304
|
+
const here = h("p", { class: "n133s-fine" }, "On this computer: ", h("a", { href: s.link, target: "_blank", rel: "noopener" }, "open NanChat"), ".");
|
|
305
|
+
return touch() ? [open("Open NanChat", s.link, true), comeBack, only] : [qr(s.link), h("p", { class: "n133s-text" }, "Scan this with your phone's camera to open NanChat, then approve the sign-in."), only, here];
|
|
306
|
+
}
|
|
307
|
+
const other = h("a", { class: "n133s-btn n133s-plain", href: s.walletLink ?? s.link }, "Another wallet");
|
|
308
|
+
const here = h("p", { class: "n133s-fine" }, "On this computer: ", h("a", { href: s.walletLink ?? s.link }, "open a wallet"), ".");
|
|
309
|
+
return touch() ? [open("Open Berry Wallet", s.link), other, comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, "Scan this with Berry Wallet on your phone, then approve the sign-in."), only, here];
|
|
310
|
+
}
|
|
311
|
+
/** The request ended without a sign-in: say why, and offer a new one. */
|
|
312
|
+
function again(s, words) {
|
|
313
|
+
current = null;
|
|
314
|
+
area.replaceChildren(backKey(), title(WALLETS[s.way].name), h("div", { class: "n133s-keys" }, key("Try again", () => void choose(s.way), "primary"), key("Choose another way", () => showWays())));
|
|
315
|
+
say(words);
|
|
316
|
+
}
|
|
317
|
+
async function poll(s) {
|
|
318
|
+
if (stopped || current !== s)
|
|
319
|
+
return;
|
|
320
|
+
if (Date.now() > s.expiresAt + 5000)
|
|
321
|
+
return again(s, "This sign-in expired.");
|
|
322
|
+
try {
|
|
323
|
+
const r = await api.post(`${base}/check`, { id: s.id, way: s.way });
|
|
324
|
+
if (stopped || current !== s)
|
|
325
|
+
return;
|
|
326
|
+
if (r.status === "signedin" && r.address)
|
|
327
|
+
return done({ address: r.address, via: "wallet" });
|
|
328
|
+
if (r.status === "elsewhere" || r.status === "used")
|
|
329
|
+
return again(s, "This sign-in was used in another browser. Start again.");
|
|
330
|
+
if (r.status === "expired")
|
|
331
|
+
return again(s, "This sign-in expired.");
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
if (e instanceof ApiError && e.status === 429)
|
|
335
|
+
say("Too many checks. Waiting a moment…");
|
|
336
|
+
}
|
|
337
|
+
timer = setTimeout(() => void poll(s), 2000);
|
|
338
|
+
}
|
|
339
|
+
function reset() {
|
|
340
|
+
stopped = false;
|
|
341
|
+
current = null;
|
|
342
|
+
showWays();
|
|
343
|
+
}
|
|
344
|
+
showWays();
|
|
345
|
+
if (passkeyOn)
|
|
346
|
+
void passkeySupport().then((s) => {
|
|
347
|
+
support = s;
|
|
348
|
+
// Only the ways' screen depends on it: redraw it when it shows.
|
|
349
|
+
if (!stopped && area.querySelector(".n133s-pk"))
|
|
350
|
+
showWays();
|
|
351
|
+
});
|
|
352
|
+
return {
|
|
353
|
+
node,
|
|
354
|
+
stop() {
|
|
355
|
+
stopped = true;
|
|
356
|
+
stopWaiting();
|
|
357
|
+
},
|
|
358
|
+
reset,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export declare function faultWords(code: PasskeyFault, rpId: string): FaultWords;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The sheet's words for each passkey fault, in plain English. The fault codes are @nano133/signin's (PasskeyFault). A fault never names a seed or a
|
|
2
|
+
// key, and never tells the player to delete a passkey.
|
|
3
|
+
export function faultWords(code, rpId) {
|
|
4
|
+
switch (code) {
|
|
5
|
+
case "cancelled":
|
|
6
|
+
return { title: "No passkey was used", text: "No passkey was used.", next: "stay" };
|
|
7
|
+
case "no-prf":
|
|
8
|
+
return { 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.", next: "wallets" };
|
|
9
|
+
case "unsupported":
|
|
10
|
+
return { title: "No passkeys here", text: "This browser has no passkeys. Use a wallet app.", next: "wallets" };
|
|
11
|
+
case "other-form":
|
|
12
|
+
return { title: "Not this password manager", text: "Your password manager answered in a way this game cannot read. In the device's sheet, choose its own passkeys (iCloud Keychain or Google Password Manager), or use a wallet app.", next: "wallets" };
|
|
13
|
+
case "not-a-purse":
|
|
14
|
+
return { title: "Not this game's passkey", text: "That passkey is not one of this game's. Choose another one, or make one.", next: "stay" };
|
|
15
|
+
case "wrong-host":
|
|
16
|
+
return { title: "Wrong address", text: `Passkeys for this game work only on ${rpId}.`, next: "wallets" };
|
|
17
|
+
case "exists":
|
|
18
|
+
return { title: "You have a passkey", text: "This device has your passkey already. Use it.", next: "stay" };
|
|
19
|
+
case "unstable":
|
|
20
|
+
return { title: "Not on this device", text: "This device gave two different answers for one passkey, so it cannot sign in here. Use a wallet app.", next: "wallets" };
|
|
21
|
+
case "other-passkey":
|
|
22
|
+
return { title: "Another passkey", text: "That is another passkey. Choose the one you just made: its name has today's date.", next: "stay" };
|
|
23
|
+
default:
|
|
24
|
+
return { title: "That did not work", text: "The passkey did not work. Try again.", next: "stay" };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -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
|
+
}
|