@nano133/nanoterra 0.11.0 → 0.13.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +110 -12
  3. package/ai/SPEC.md +11 -4
  4. package/dist/admin/guard.d.ts +9 -1
  5. package/dist/admin/guard.js +12 -4
  6. package/dist/admin/panel/card.d.ts +5 -0
  7. package/dist/admin/panel/card.js +3 -0
  8. package/dist/admin/panel/icons.d.ts +9 -0
  9. package/dist/admin/panel/icons.js +1 -0
  10. package/dist/admin/panel/index.d.ts +1 -1
  11. package/dist/admin/panel/mount.d.ts +7 -0
  12. package/dist/admin/panel/mount.js +4 -1
  13. package/dist/admin-signin/adapters/d1.d.ts +3 -1
  14. package/dist/admin-signin/adapters/d1.js +58 -3
  15. package/dist/admin-signin/adapters/firestore.d.ts +5 -2
  16. package/dist/admin-signin/adapters/firestore.js +74 -5
  17. package/dist/admin-signin/conformance.d.ts +8 -1
  18. package/dist/admin-signin/conformance.js +88 -3
  19. package/dist/admin-signin/handler.d.ts +7 -0
  20. package/dist/admin-signin/handler.js +134 -6
  21. package/dist/admin-signin/index.d.ts +2 -2
  22. package/dist/admin-signin/index.js +3 -2
  23. package/dist/admin-signin/memory.d.ts +6 -2
  24. package/dist/admin-signin/memory.js +47 -1
  25. package/dist/admin-signin/panel/index.d.ts +3 -0
  26. package/dist/admin-signin/panel/index.js +4 -2
  27. package/dist/admin-signin/panel/passkeys.d.ts +43 -0
  28. package/dist/admin-signin/panel/passkeys.js +202 -0
  29. package/dist/admin-signin/panel/view.d.ts +12 -0
  30. package/dist/admin-signin/panel/view.js +48 -1
  31. package/dist/admin-signin/types.d.ts +57 -7
  32. package/dist/admin-signin/types.js +20 -3
  33. package/dist/input/index.d.ts +2 -1
  34. package/dist/input/index.js +3 -0
  35. package/dist/input/zoom.d.ts +81 -0
  36. package/dist/input/zoom.js +140 -0
  37. package/package.json +1 -1
  38. package/src/admin/guard.ts +19 -5
  39. package/src/admin/panel/card.ts +5 -0
  40. package/src/admin/panel/icons.ts +1 -0
  41. package/src/admin/panel/index.ts +1 -1
  42. package/src/admin/panel/mount.ts +11 -1
  43. package/src/admin/panel/style.css +44 -0
  44. package/src/admin-signin/adapters/d1.sql +20 -3
  45. package/src/admin-signin/adapters/d1.ts +58 -5
  46. package/src/admin-signin/adapters/firestore.ts +73 -6
  47. package/src/admin-signin/conformance.ts +99 -3
  48. package/src/admin-signin/handler.ts +131 -4
  49. package/src/admin-signin/index.ts +7 -2
  50. package/src/admin-signin/memory.ts +43 -2
  51. package/src/admin-signin/panel/index.ts +6 -2
  52. package/src/admin-signin/panel/passkeys.ts +245 -0
  53. package/src/admin-signin/panel/view.ts +63 -1
  54. package/src/admin-signin/types.ts +62 -11
  55. package/src/input/index.ts +5 -0
  56. package/src/input/zoom.ts +160 -0
  57. package/src/player-signin/panel/style.css +3 -2
@@ -0,0 +1,43 @@
1
+ import type { PasskeyScheme } from "@nano133/signin/passkey";
2
+ import type { AdminCard } from "../../admin/panel/card.js";
3
+ import { type AdminPasskey } from "../types.js";
4
+ export type AdminPasskeyOptions = {
5
+ /** The game's FROZEN passkey scheme: the same one the player sheet takes (see "/player-signin": checkScheme, schemeVector). */
6
+ scheme: PasskeyScheme;
7
+ /** The game's name: the device shows it next to a new passkey ("Coin Bees admin · 1 Nov 2026"). */
8
+ name: string;
9
+ /** The passkeys' relying party id: the game's domain. Default: the page's host name. */
10
+ rpId?: string;
11
+ /** Where the admin sign-in's routes are. Default "/api/admin-signin" (ADMIN_SIGNIN_BASE). */
12
+ base?: string;
13
+ /** The `fetch` to use (default: the page's). */
14
+ fetch?: typeof fetch;
15
+ };
16
+ /** The answer of `POST {base}/passkeys` (and of a link or a removal). */
17
+ export type PasskeyList = {
18
+ passkeys: AdminPasskey[];
19
+ limit: number;
20
+ via: string;
21
+ canChange: boolean;
22
+ you: string;
23
+ };
24
+ /** What the owner reads when a passkey prompt fails, by the fault's code (@nano133/signin/passkey's PasskeyFault). */
25
+ export declare function passkeyFaultText(e: unknown): string;
26
+ /**
27
+ * Signs in to /admin with a linked passkey: the device asks for the passkey, the purse signs the ADMIN text, the server checks the link. Resolves with the
28
+ * passkey's address; throws an ApiError (the server's words) or a passkey fault (`passkeyFaultText`).
29
+ */
30
+ export declare function passkeyAdminSignIn(o: AdminPasskeyOptions & {
31
+ turnstile?: string | null;
32
+ signal?: AbortSignal;
33
+ }): Promise<{
34
+ address: string;
35
+ linkedTo: string;
36
+ }>;
37
+ /**
38
+ * The "Owner passkeys" card for /admin: the passkeys linked to the game's address, "Add a passkey" and Remove (both only with a wallet sign-in). Give it to
39
+ * `mountAdmin`'s `cards`; it reads the list each time the owner signs in.
40
+ *
41
+ * mountAdmin(el, { looks, signIn: adminSignIn({ passkeys: true, scheme, name }), signOut: adminSignOut(), cards: [ownerPasskeys({ scheme, name })] });
42
+ */
43
+ export declare function ownerPasskeys(options: AdminPasskeyOptions): AdminCard;
@@ -0,0 +1,202 @@
1
+ // The owner's passkeys in the browser (since 0.12.0): the "Passkey" way of the admin's sign-in, and the "Owner passkeys" card for /admin (`ownerPasskeys()`,
2
+ // a card for `mountAdmin`'s `cards`). A passkey is only a key to /admin: the money stays in the owner's wallet.
3
+ //
4
+ // - Sign in: the passkey purse (the game's FROZEN scheme, the player sheet's) signs "ADMIN of <host>: sign in as <address> at <ms>"; the server lets it in
5
+ // only while the passkey is linked to the game's address.
6
+ // - Add: only with a WALLET admin session. A new passkey is made (or one the owner has is used), and it signs "Link this passkey to the ADMIN of <host>:
7
+ // ...": the server writes the link only with both proofs, the wallet session and this signature.
8
+ // - Remove: only with a wallet admin session; the passkey's admin sessions end too.
9
+ //
10
+ // The passkey code (@nano133/signin/passkey) and the purse's signing code (@nano133/signin/purse) are loaded only when a passkey is used: a game without
11
+ // passkeys never loads them. The seed lives in local variables of one press only: never in storage, a log, an error text or an address.
12
+ import { ApiError, createApi } from "../../admin/panel/api.js";
13
+ import { h, shortAddress } from "../../admin/panel/dom.js";
14
+ import { icon } from "../../admin/panel/icons.js";
15
+ import { checkScheme } from "../../player-signin/scheme-check.js";
16
+ import { ADMIN_SIGNIN_BASE, adminLinkDomain, adminPasskeyDomain } from "../types.js";
17
+ /** What the owner reads when a passkey prompt fails, by the fault's code (@nano133/signin/passkey's PasskeyFault). */
18
+ export function passkeyFaultText(e) {
19
+ const code = e?.code;
20
+ const name = e?.name;
21
+ const c = typeof code === "string" ? code : name === "NotAllowedError" || name === "AbortError" ? "cancelled" : name === "SecurityError" ? "wrong-host" : name === "NotSupportedError" ? "unsupported" : name === "InvalidStateError" ? "exists" : "failed";
22
+ if (c === "cancelled")
23
+ return "The passkey was closed. Try again.";
24
+ if (c === "no-prf" || c === "other-form" || c === "unsupported")
25
+ return "This browser or device cannot use a passkey here. Use your wallet.";
26
+ if (c === "not-a-purse")
27
+ return "That passkey is not one of this game's. Choose another one.";
28
+ if (c === "wrong-host")
29
+ return "A passkey works only on the game's own address.";
30
+ if (c === "exists")
31
+ return "This device has a passkey for this game already. Press Use a passkey I have.";
32
+ return "The passkey did not work. Try again.";
33
+ }
34
+ const dayText = (ms) => new Date(ms).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
35
+ function setupOf(o) {
36
+ return { rpId: o.rpId ?? location.hostname, rpName: o.name, scheme: o.scheme, userName: `${o.name} admin · ${dayText(Date.now())}`, exclude: [] };
37
+ }
38
+ const apiOf = (o) => createApi(o.base ?? ADMIN_SIGNIN_BASE, o.fetch ?? ((...a) => fetch(...a)));
39
+ /**
40
+ * Signs in to /admin with a linked passkey: the device asks for the passkey, the purse signs the ADMIN text, the server checks the link. Resolves with the
41
+ * passkey's address; throws an ApiError (the server's words) or a passkey fault (`passkeyFaultText`).
42
+ */
43
+ export async function passkeyAdminSignIn(o) {
44
+ checkScheme(o.scheme);
45
+ const { openPasskey } = await import("@nano133/signin/passkey");
46
+ const p = await openPasskey(setupOf(o), o.signal);
47
+ const { signinBody } = await import("@nano133/signin/purse");
48
+ return apiOf(o).post("/passkey", { ...signinBody(adminPasskeyDomain(location.host), p.seed), ...(o.turnstile ? { turnstile: o.turnstile } : {}) });
49
+ }
50
+ /**
51
+ * The "Owner passkeys" card for /admin: the passkeys linked to the game's address, "Add a passkey" and Remove (both only with a wallet sign-in). Give it to
52
+ * `mountAdmin`'s `cards`; it reads the list each time the owner signs in.
53
+ *
54
+ * mountAdmin(el, { looks, signIn: adminSignIn({ passkeys: true, scheme, name }), signOut: adminSignOut(), cards: [ownerPasskeys({ scheme, name })] });
55
+ */
56
+ export function ownerPasskeys(options) {
57
+ checkScheme(options.scheme);
58
+ const api = apiOf(options);
59
+ let list = null;
60
+ let prompt = null;
61
+ const status = h("p", { class: "fine passkey-status", role: "status", "aria-live": "polite" });
62
+ const items = h("ul", { class: "signin-list passkey-list" });
63
+ const note = h("p", { class: "fine" });
64
+ const add = h("div", { class: "passkey-add" });
65
+ const el = h("section", { class: "admin-card owner-passkeys", "aria-labelledby": "n133-passkeys-title" }, h("h2", { id: "n133-passkeys-title" }, "Owner passkeys"), h("p", { class: "fine" }, "A passkey opens this admin page with Face ID, Touch ID or your device PIN, without your wallet. It cannot move money: your money stays in your wallet."), items, note, add, status);
66
+ el.hidden = true;
67
+ const say = (text) => (status.textContent = text);
68
+ const key = (text, onclick, kind = "plain") => h("button", { class: `btn${kind === "primary" ? " btn-primary" : ""}`, type: "button", onclick }, text);
69
+ const busy = (on) => {
70
+ for (const b of Array.from(el.querySelectorAll("button")))
71
+ b.disabled = on;
72
+ el.toggleAttribute("aria-busy", on);
73
+ };
74
+ const words = (e, fallback) => (e instanceof ApiError ? e.message : e && typeof e === "object" && ("code" in e || "name" in e) ? passkeyFaultText(e) : fallback);
75
+ function draw() {
76
+ if (!list)
77
+ return;
78
+ const l = list;
79
+ items.replaceChildren(...l.passkeys.map((p) => {
80
+ const row = h("li", { class: "passkey-row" });
81
+ const what = h("span", { class: "passkey-what" }, icon("key", 18), h("strong", {}, h("code", { title: p.address }, shortAddress(p.address))), h("span", {}, ` · added ${dayText(p.createdAt)}`), p.address === l.you && l.via === "passkey" ? h("span", { class: "chip" }, "This passkey") : null);
82
+ row.append(what);
83
+ if (l.canChange)
84
+ row.append(key("Remove", () => confirmRemove(row, p.address)));
85
+ return row;
86
+ }));
87
+ if (!l.passkeys.length)
88
+ items.append(h("li", { class: "fine" }, "No passkey is linked yet."));
89
+ note.textContent = l.canChange ? `You can link up to ${l.limit} passkeys. Each one proves its key when you add it.` : "You signed in with a passkey. Sign in with your wallet to add or remove a passkey.";
90
+ showAdd();
91
+ }
92
+ function showAdd() {
93
+ if (!list?.canChange)
94
+ return add.replaceChildren();
95
+ const full = list.passkeys.length >= list.limit;
96
+ const b = key("Add a passkey", () => chooseAdd(), "primary");
97
+ b.disabled = full;
98
+ add.replaceChildren(b, full ? h("p", { class: "fine" }, `You have ${list.limit} passkeys. Remove one to add another.`) : "");
99
+ }
100
+ function chooseAdd() {
101
+ say("");
102
+ add.replaceChildren(h("p", {}, "Make a new passkey on this device, or use a passkey you have for this game."), h("div", { class: "passkey-keys" }, key("Make a new passkey", () => void makeNew(), "primary"), key("Use a passkey I have", () => void useMine()), key("Cancel", () => showAdd())));
103
+ add.querySelector("button")?.focus({ preventScroll: true });
104
+ }
105
+ /** Links the passkey of `seed`: it signs the LINK text, and the server writes the link with this wallet session. */
106
+ async function linkSeed(seed) {
107
+ const { signinBody } = await import("@nano133/signin/purse");
108
+ list = await api.post("/passkey/link", signinBody(adminLinkDomain(location.host), seed));
109
+ draw();
110
+ say("Passkey added. It opens this admin page from now on.");
111
+ }
112
+ async function makeNew() {
113
+ busy(true);
114
+ say("Making a passkey… Follow your device.");
115
+ prompt = new AbortController();
116
+ try {
117
+ const pk = await import("@nano133/signin/passkey");
118
+ const made = await pk.makePasskey(setupOf(options), prompt.signal);
119
+ await firstOpen(made, false);
120
+ }
121
+ catch (e) {
122
+ busy(false);
123
+ showAdd();
124
+ say(words(e, "The passkey was not added. Try again."));
125
+ }
126
+ }
127
+ /** The first use of a new passkey gives its seed. Where the browser allows one prompt per press, one more key finishes it. */
128
+ async function firstOpen(made, pick) {
129
+ busy(true);
130
+ say("Opening the new passkey…");
131
+ prompt = new AbortController();
132
+ const pk = await import("@nano133/signin/passkey");
133
+ try {
134
+ const p = await pk.firstUse(setupOf(options), made, prompt.signal, pick);
135
+ await linkSeed(p.seed);
136
+ busy(false);
137
+ }
138
+ catch (e) {
139
+ busy(false);
140
+ const code = e?.code;
141
+ if (code !== "cancelled" && code !== "failed")
142
+ throw e;
143
+ add.replaceChildren(h("p", {}, "Your passkey is made. Open it once to add it."), h("div", { class: "passkey-keys" }, key("Open the new passkey", () => void firstOpen(made, true).catch(fault), "primary"), key("Cancel", () => showAdd())));
144
+ say("");
145
+ }
146
+ }
147
+ function fault(e) {
148
+ busy(false);
149
+ showAdd();
150
+ say(words(e, "The passkey was not added. Try again."));
151
+ }
152
+ async function useMine() {
153
+ busy(true);
154
+ say("Waiting for your passkey…");
155
+ prompt = new AbortController();
156
+ try {
157
+ const pk = await import("@nano133/signin/passkey");
158
+ const p = await pk.openPasskey(setupOf(options), prompt.signal);
159
+ await linkSeed(p.seed);
160
+ busy(false);
161
+ }
162
+ catch (e) {
163
+ fault(e);
164
+ }
165
+ }
166
+ function confirmRemove(row, address) {
167
+ const keys = h("div", { class: "passkey-confirm" }, h("p", {}, "Remove this passkey? It cannot open this page any more, and its sign-ins end now."), key("Yes, remove", () => void remove(address), "primary"), key("Cancel", () => draw()));
168
+ row.replaceChildren(row.firstChild, keys);
169
+ keys.querySelector("button")?.focus({ preventScroll: true });
170
+ }
171
+ async function remove(address) {
172
+ busy(true);
173
+ say("Removing…");
174
+ try {
175
+ list = await api.post("/passkey/unlink", { address });
176
+ busy(false);
177
+ draw();
178
+ say("Passkey removed.");
179
+ }
180
+ catch (e) {
181
+ busy(false);
182
+ draw();
183
+ say(words(e, "The game's server did not answer. Nothing was changed."));
184
+ }
185
+ }
186
+ async function refresh() {
187
+ prompt?.abort();
188
+ prompt = null;
189
+ try {
190
+ list = await api.post("/passkeys");
191
+ el.hidden = false;
192
+ say("");
193
+ draw();
194
+ }
195
+ catch {
196
+ // No passkeys on this game (the route is off), or no session that may read them: the card does not show.
197
+ list = null;
198
+ el.hidden = true;
199
+ }
200
+ }
201
+ return { el, refresh };
202
+ }
@@ -1,3 +1,4 @@
1
+ import type { PasskeyScheme } from "@nano133/signin/passkey";
1
2
  export type AdminSigninViewOptions = {
2
3
  /** Where the sign-in's routes are. Default "/api/admin-signin" (ADMIN_SIGNIN_BASE). */
3
4
  base?: string;
@@ -6,6 +7,17 @@ export type AdminSigninViewOptions = {
6
7
  * admin sign-in from a game sign-in in it: it is off unless the game turns it on on the server and here.
7
8
  */
8
9
  nanchat?: boolean;
10
+ /**
11
+ * Offer the owner's linked passkeys (the server's `passkeys: true`): a "Passkey" tile after the wallets. Default false. Needs `scheme` and `name`. Only a
12
+ * passkey that the owner linked in Settings while signed in with the wallet opens /admin.
13
+ */
14
+ passkeys?: boolean;
15
+ /** The game's FROZEN passkey scheme: the same one the player sheet takes. Needed with `passkeys`. */
16
+ scheme?: PasskeyScheme;
17
+ /** The game's name (the device shows it with the passkey). Needed with `passkeys`. */
18
+ name?: string;
19
+ /** The passkeys' relying party id: the game's domain. Default: the page's host name. */
20
+ rpId?: string;
9
21
  /** The person check (Turnstile): fills `box` and gives its token, before a start. No hook: no check. */
10
22
  personCheck?: (box: HTMLElement) => Promise<string | null | undefined>;
11
23
  /** Draws a QR code of `text` (a wallet's link), for a screen with a mouse. Default: the package's own QR code; a link too long for it is shown as a link. */
@@ -5,11 +5,16 @@
5
5
  //
6
6
  // Plain DOM, no framework, no package. The tiles show letters, not logos: no wallet's licence clearly allows its logo. Phone first: text 16 px, every
7
7
  // key and tile 48 px or more each way. It uses the player sheet's stylesheet ("@nano133/nanoterra/admin-signin/style.css" is the same file).
8
+ //
9
+ // With `passkeys: true` (and the game's frozen `scheme` and `name`), a "Passkey" tile comes after the wallets: a passkey that the owner linked in Settings
10
+ // ("Owner passkeys", ./passkeys.ts) opens /admin. A passkey that is not linked is refused by the server.
8
11
  import { ApiError, createApi } from "../../admin/panel/api.js";
9
12
  import { h, shortAddress } from "../../admin/panel/dom.js";
10
13
  import { icon } from "../../admin/panel/icons.js";
14
+ import { checkScheme } from "../../player-signin/scheme-check.js";
11
15
  import { qrSvg } from "../../shop/panel/qr.js";
12
16
  import { ADMIN_SIGNIN_BASE } from "../types.js";
17
+ import { passkeyAdminSignIn, passkeyFaultText } from "./passkeys.js";
13
18
  /** The wallets, in every list's order: Berry Wallet first. */
14
19
  const WALLETS = [
15
20
  { way: "berry", name: "Berry Wallet", mark: "B" },
@@ -25,6 +30,13 @@ export function adminSigninView(options = {}) {
25
30
  const base = options.base ?? ADMIN_SIGNIN_BASE;
26
31
  const api = createApi(base, options.fetch ?? ((...a) => fetch(...a)));
27
32
  const wallets = WALLETS.filter((w) => w.way !== "nanchat" || options.nanchat === true);
33
+ const passkeysOn = options.passkeys === true;
34
+ if (passkeysOn) {
35
+ checkScheme(options.scheme);
36
+ if (typeof options.name !== "string" || !options.name.trim())
37
+ throw new Error("passkeys: true needs the game's name");
38
+ }
39
+ let prompt = null;
28
40
  const touch = () => options.touch ?? (typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches);
29
41
  let stopped = false;
30
42
  let timer;
@@ -41,11 +53,46 @@ export function adminSigninView(options = {}) {
41
53
  if (timer)
42
54
  clearTimeout(timer);
43
55
  timer = undefined;
56
+ prompt?.abort();
57
+ prompt = null;
44
58
  }
45
59
  function showTiles() {
46
60
  stopWaiting();
47
61
  const tiles = wallets.map((w) => h("button", { class: "n133s-tile", type: "button", "data-way": w.way, onclick: () => void choose(w.way) }, h("span", { class: "n133s-mark", "aria-hidden": "true" }, w.mark), h("span", { class: "n133s-name" }, w.name)));
48
- area.replaceChildren(h("p", { class: "n133s-or" }, "Choose your wallet"), h("div", { class: "n133s-tiles", "data-count": String(tiles.length) }, ...tiles));
62
+ // The owner's linked passkey comes after the wallets: a wallet makes the first admin login, and only it links a passkey.
63
+ if (passkeysOn)
64
+ tiles.push(h("button", { class: "n133s-tile", type: "button", "data-way": "passkey", onclick: () => showPasskey() }, h("span", { class: "n133s-mark", "aria-hidden": "true" }, icon("key", 24)), h("span", { class: "n133s-name" }, "Passkey")));
65
+ area.replaceChildren(h("p", { class: "n133s-or" }, passkeysOn ? "Choose your wallet or your passkey" : "Choose your wallet"), h("div", { class: "n133s-tiles", "data-count": String(tiles.length) }, ...tiles));
66
+ }
67
+ // ---- the owner's linked passkey ----
68
+ function showPasskey(words = "") {
69
+ stopWaiting();
70
+ current = null;
71
+ const back = h("button", { class: "n133s-back", type: "button", onclick: () => showTiles() }, icon("back", 18), h("span", {}, "Back"));
72
+ area.replaceChildren(back, h("h3", { class: "n133s-step", tabindex: "-1" }, "Passkey"), h("p", { class: "n133s-text" }, "Use a passkey that you linked to the game's address in Settings. Your device asks for Face ID, Touch ID or its PIN."), h("div", { class: "n133s-keys" }, key("Use my passkey", () => void usePasskey(), "primary")), h("p", { class: "n133s-fine" }, "No passkey yet? Sign in with your wallet, then add one in Settings, Owner passkeys."));
73
+ say(words);
74
+ focusFirst();
75
+ }
76
+ async function usePasskey() {
77
+ stopWaiting();
78
+ for (const b of Array.from(area.querySelectorAll("button")))
79
+ b.disabled = true;
80
+ say("Waiting for your passkey…");
81
+ prompt = new AbortController();
82
+ try {
83
+ const turnstile = options.personCheck ? await options.personCheck(check) : undefined;
84
+ const r = await passkeyAdminSignIn({ scheme: options.scheme, name: options.name, rpId: options.rpId, base, fetch: options.fetch, turnstile, signal: prompt.signal });
85
+ if (stopped)
86
+ return;
87
+ area.replaceChildren(h("p", { class: "n133s-done" }, icon("check", 22), h("span", {}, `Signed in with your passkey ${shortAddress(r.address)}`)));
88
+ say("");
89
+ options.onSignedIn?.(r.address);
90
+ }
91
+ catch (e) {
92
+ if (stopped)
93
+ return;
94
+ showPasskey(e instanceof ApiError ? e.message : passkeyFaultText(e));
95
+ }
49
96
  }
50
97
  async function choose(way) {
51
98
  if (current?.way === way && Date.now() < current.expiresAt)
@@ -51,15 +51,38 @@ export declare const ADMIN_SIGNIN_LIMITS: {
51
51
  readonly limit: 5;
52
52
  readonly windowMs: number;
53
53
  };
54
+ /** The owner's passkeys: the list, a link and a removal, per IP address (memory). A passkey's sign-in counts with the starts (`signin`, hook). */
55
+ readonly passkeys: {
56
+ readonly limit: 30;
57
+ readonly windowMs: 60000;
58
+ };
54
59
  /** The biggest body the routes read. */
55
60
  readonly bodyMax: 8192;
56
61
  };
62
+ /** The most passkeys one owner may link. */
63
+ export declare const ADMIN_PASSKEY_LIMIT = 10;
64
+ /** How long a passkey's signature is kept as used (its 5-minute window, both ways, and some more). */
65
+ export declare const ADMIN_ONCE_MS: number;
66
+ /**
67
+ * What a passkey signs to OPEN /admin: `signinMessage(adminPasskeyDomain(host), address, at)`, "ADMIN of <host>: sign in as nano_... at <ms>". Never the
68
+ * host alone: a player's passkey signature names only the host, and it must never open /admin.
69
+ */
70
+ export declare const adminPasskeyDomain: (host: string) => string;
71
+ /** What a passkey signs to be LINKED to the owner: "Link this passkey to the ADMIN of <host>: sign in as nano_... at <ms>". It opens nothing. */
72
+ export declare const adminLinkDomain: (host: string) => string;
73
+ /** One passkey linked to the owner: the passkey purse's address and when it was linked (ms). */
74
+ export type AdminPasskey = {
75
+ address: string;
76
+ createdAt: number;
77
+ };
78
+ /** What a link gives: "linked", "exists" (it was linked already: nothing changed) or "full" (the owner has ADMIN_PASSKEY_LIMIT already). */
79
+ export type AdminLinkResult = "linked" | "exists" | "full";
57
80
  /** A token, a starter value: 32 random bytes as 64 hex characters. */
58
81
  export declare const TOKEN_PATTERN: RegExp;
59
82
  /**
60
83
  * An admin session as a store keeps it. `idHash` is the SHA-256 (hex) of "admin:" and the cookie's token (`adminKey`): never the token, and never the
61
- * plain hash a player's session has, so the SERVER keeps the two scopes apart and not only the cookies' names. `via` is "wallet" for every session this
62
- * sign-in makes; a store returns the word it holds.
84
+ * plain hash a player's session has, so the SERVER keeps the two scopes apart and not only the cookies' names. `via` is "wallet" for a wallet's session
85
+ * and "passkey" for a linked passkey's (its `address` is the PASSKEY's, never the owner's); a store returns the word it holds.
63
86
  */
64
87
  export type AdminSessionRecord = {
65
88
  idHash: string;
@@ -68,16 +91,28 @@ export type AdminSessionRecord = {
68
91
  expiresAt: number;
69
92
  via: string;
70
93
  };
71
- /** The admin session as the game reads it (`createAdminHandler`'s `session` hook). `via` is "unknown" for a stored word other than "wallet" and "nanoterra": the guard refuses it. */
94
+ /**
95
+ * The admin session as the game reads it (`createAdminHandler`'s `session` hook). `via` is "unknown" for a stored word other than "wallet", "nanoterra" and
96
+ * "passkey": the guard refuses it. A "passkey" session carries `linkedTo`, the owner's address, only while its link to the CURRENT owner exists (read on this
97
+ * request); without the link the sign-in gives no session at all.
98
+ */
72
99
  export type AdminSigninSession = {
73
100
  address: string;
74
101
  createdAt: number;
75
102
  via: "wallet" | "nanoterra" | "unknown";
103
+ } | {
104
+ address: string;
105
+ createdAt: number;
106
+ via: "passkey";
107
+ linkedTo: string;
76
108
  };
77
109
  /**
78
- * The game's database for the admin's sign-in. An adapter reads and writes ONLY its three tables or collections (requests, sessions, the sign-in list),
79
- * and only the ADMIN's rows of the first two (a game whose players sign in through the same tables keeps its rows apart: the `scope` column).
80
- * Run `adminSigninStoreConformance` on a store you write yourself.
110
+ * The game's database for the admin's sign-in. An adapter reads and writes ONLY its tables or collections (requests, sessions, the sign-in list, and for
111
+ * the owner's passkeys the links and the used signatures), and only the ADMIN's rows of the first two (a game whose players sign in through the same tables
112
+ * keeps its rows apart: the `scope` column). Run `adminSigninStoreConformance` on a store you write yourself.
113
+ *
114
+ * The passkey methods (`createSession` to `useOnce`) are optional: a store without them works for wallets, and `createAdminSignin({ passkeys: true })`
115
+ * refuses it.
81
116
  */
82
117
  export interface AdminSigninStore {
83
118
  /** The admin requests of one wallet app. */
@@ -100,6 +135,21 @@ export interface AdminSigninStore {
100
135
  recordLogin(login: AdminLogin): Promise<void>;
101
136
  /** The last sign-ins, newest first, at most `limit`, with only `at`, `country` and `browser`. */
102
137
  recentLogins(limit: number): Promise<AdminLogin[]>;
103
- /** Deletes what nobody needs any more: admin requests 2 hours after they ended, admin sessions that ended, sign-in rows older than 30 days. */
138
+ /** Deletes what nobody needs any more: admin requests 2 hours after they ended, admin sessions that ended, sign-in rows older than 30 days, used passkey signatures past their time. */
104
139
  clean(now: number): Promise<void>;
140
+ /** Writes an admin session (a linked passkey's sign-in). Throws when a session with this hash exists. */
141
+ createSession?(session: AdminSessionRecord): Promise<void>;
142
+ /**
143
+ * Links the passkey `address` to `owner`, in ONE step that holds the limit: "linked"; "exists" when it is linked already (nothing changes); "full" when
144
+ * `owner` has ADMIN_PASSKEY_LIMIT links (nothing is written). Two calls at once never make more than the limit.
145
+ */
146
+ linkPasskey?(owner: string, address: string, at: number): Promise<AdminLinkResult>;
147
+ /** Removes the link and ends every admin session of that passkey ("passkey" sessions of `address`). True when a link was there. */
148
+ unlinkPasskey?(owner: string, address: string): Promise<boolean>;
149
+ /** The passkeys linked to `owner`, oldest first. */
150
+ passkeys?(owner: string): Promise<AdminPasskey[]>;
151
+ /** True only when `address` is linked to `owner` NOW (the exact texts). */
152
+ linked?(owner: string, address: string): Promise<boolean>;
153
+ /** Records `key` (a used passkey signature) and answers true; false when it is there and its time is not over. The admin's own table, not the player's. */
154
+ useOnce?(key: string, ttlMs: number): Promise<boolean>;
105
155
  }
@@ -1,9 +1,13 @@
1
1
  // The ADMIN's sign-in of a game: the fixed route, the times, the limits and the store contract. (The player's sign-in is another thing:
2
2
  // "@nano133/nanoterra/player-signin".) No package and no DOM: the stores, the adapters and the browser panel read this file.
3
3
  //
4
- // The admin signs in with a wallet app only: Berry Wallet or Nautilus (and NanChat only when the game turns it on: its text cannot name the ADMIN). No
5
- // passkey. What Berry Wallet and Nautilus sign names the ADMIN, so a link that a stranger sends to the owner does not look like any sign-in. Only the game's own address makes an admin login, and only from a request that was started
6
- // on the admin's route; the guard (`adminAccess`) decides again on every call of /admin.
4
+ // The admin signs in with a wallet app: Berry Wallet or Nautilus (and NanChat only when the game turns it on: its text cannot name the ADMIN). What Berry
5
+ // Wallet and Nautilus sign names the ADMIN, so a link that a stranger sends to the owner does not look like any sign-in. Only the game's own address makes a
6
+ // wallet admin login, and only from a request that was started on the admin's route; the guard (`adminAccess`) decides again on every call of /admin.
7
+ //
8
+ // Since 0.12.0, with `passkeys: true`, the owner may also LINK passkeys (at most 10): a passkey is only a key to /admin, the money stays in the owner's
9
+ // wallet. A link is written only from a live WALLET admin session of the owner and with the passkey's own signature (both sides prove their key), and a
10
+ // passkey's admin login counts only while its link to the CURRENT owner exists (read again on every request).
7
11
  /** The admin's sign-in route, the same in every game: a page, an AI helper and the kit's panel find it here. */
8
12
  export const ADMIN_SIGNIN_BASE = "/api/admin-signin";
9
13
  /** The page the admin signs in to: Berry Wallet signs this page's address ("URI: https://example.com/admin", with NO query). */
@@ -35,8 +39,21 @@ export const ADMIN_SIGNIN_LIMITS = {
35
39
  nanchatRoute: { limit: 120, windowMs: 60_000 },
36
40
  nanchatAll: { limit: 600, windowMs: 10 * 60_000 },
37
41
  nanchatNonce: { limit: 5, windowMs: 10 * 60_000 },
42
+ /** The owner's passkeys: the list, a link and a removal, per IP address (memory). A passkey's sign-in counts with the starts (`signin`, hook). */
43
+ passkeys: { limit: 30, windowMs: 60_000 },
38
44
  /** The biggest body the routes read. */
39
45
  bodyMax: 8192,
40
46
  };
47
+ /** The most passkeys one owner may link. */
48
+ export const ADMIN_PASSKEY_LIMIT = 10;
49
+ /** How long a passkey's signature is kept as used (its 5-minute window, both ways, and some more). */
50
+ export const ADMIN_ONCE_MS = 10 * 60_000;
51
+ /**
52
+ * What a passkey signs to OPEN /admin: `signinMessage(adminPasskeyDomain(host), address, at)`, "ADMIN of <host>: sign in as nano_... at <ms>". Never the
53
+ * host alone: a player's passkey signature names only the host, and it must never open /admin.
54
+ */
55
+ export const adminPasskeyDomain = (host) => `ADMIN of ${host}`;
56
+ /** What a passkey signs to be LINKED to the owner: "Link this passkey to the ADMIN of <host>: sign in as nano_... at <ms>". It opens nothing. */
57
+ export const adminLinkDomain = (host) => `Link this passkey to the ADMIN of ${host}`;
41
58
  /** A token, a starter value: 32 random bytes as 64 hex characters. */
42
59
  export const TOKEN_PATTERN = /^[0-9a-f]{64}$/;
@@ -1,3 +1,5 @@
1
+ export { createZoom, listenZoom, pinchedTo, wheelNotches, zoomedBy, PINCH, ZOOM_CURVE, ZOOM_EASE, ZOOM_STEP } from "./zoom.js";
2
+ export type { Zoom, ZoomRange } from "./zoom.js";
1
3
  type Listens = Pick<EventTarget, "addEventListener" | "removeEventListener">;
2
4
  /**
3
5
  * Calls `release` each time what is held may be wrong. In it, clear every held key, thumb stick and drag, and any jump or tool that a key press queued.
@@ -29,4 +31,3 @@ export declare function pauseWhenLeft(pause: () => void, win?: Listens, doc?: Li
29
31
  hidden?: boolean;
30
32
  hasFocus?: () => boolean;
31
33
  }): () => void;
32
- export {};
@@ -16,6 +16,9 @@
16
16
  // calls your `pause` when the player really left (a free round stops its clock and shows a Resume key). No package: plain browser events.
17
17
  //
18
18
  // A ROUND THAT COSTS MONEY must not pause: its deadline must not move. Keep `releaseWhenLost` (the character always stops) and leave `pauseWhenLeft` out.
19
+ //
20
+ // The camera's distance (the wheel, a pinch, the + and − keys, one curve) is `createZoom` and `listenZoom`, in ./zoom.ts.
21
+ export { createZoom, listenZoom, pinchedTo, wheelNotches, zoomedBy, PINCH, ZOOM_CURVE, ZOOM_EASE, ZOOM_STEP } from "./zoom.js";
19
22
  /**
20
23
  * Calls `release` each time what is held may be wrong. In it, clear every held key, thumb stick and drag, and any jump or tool that a key press queued.
21
24
  * `keysOnly`, when given, is called when a menu opens (a right click): a menu takes a keyup, but on a touch screen a long press also asks for a menu,
@@ -0,0 +1,81 @@
1
+ /** Where the camera may be (metres from the eyes): the farthest, the default, and the switch: nearer than `first` it is in the eyes. */
2
+ export type ZoomRange = {
3
+ max: number;
4
+ start: number;
5
+ first: number;
6
+ };
7
+ /** One notch: about one mouse-wheel click (100 px of scroll). The + and − keys move `key` notches. */
8
+ export declare const ZOOM_STEP: {
9
+ readonly perPx: 0.006;
10
+ readonly perPxPinch: 0.03;
11
+ readonly linePx: 16;
12
+ readonly pagePx: 400;
13
+ readonly key: 1.2;
14
+ };
15
+ /** The curve: a notch moves `base + scale × distance` metres. */
16
+ export declare const ZOOM_CURVE: {
17
+ readonly base: 0.35;
18
+ readonly scale: 0.14;
19
+ };
20
+ /** The touch pinch: twice the spread divides `distance + base/scale` by 2^power. */
21
+ export declare const PINCH: {
22
+ readonly power: 1.3;
23
+ };
24
+ /** How fast the camera eases to the distance asked for (1/s). */
25
+ export declare const ZOOM_EASE = 9;
26
+ /** One wheel event as notches (negative: in). A mouse notch arrives in lines or about 100 px, a trackpad in many small pixels, a pinch as a wheel with the ctrl key. */
27
+ export declare function wheelNotches(e: {
28
+ deltaY: number;
29
+ deltaMode?: number;
30
+ ctrlKey?: boolean;
31
+ }): number;
32
+ /** The distance after `notches` (negative: in) from `want`. */
33
+ export declare function zoomedBy(r: ZoomRange, want: number, notches: number): number;
34
+ /** The distance during a touch pinch: `from` at the start, the fingers `spread0` apart then and `spread` now (px). Apart brings it in. */
35
+ export declare function pinchedTo(r: ZoomRange, from: number, spread0: number, spread: number): number;
36
+ export type Zoom = {
37
+ /** The distance asked for (metres from the eyes). */
38
+ want(): number;
39
+ /** The distance now, as the last `step` eased it. */
40
+ now(): number;
41
+ /** True while the camera is in the eyes (the distance now below `first`). */
42
+ eyes(): boolean;
43
+ /** Asks for a distance (clamped), writes it and tells the listeners. */
44
+ set(d: number): void;
45
+ /** Moves the distance by notches (negative: in), along the curve. */
46
+ by(notches: number): void;
47
+ /** A wheel event (or one like it) moves the distance. */
48
+ wheel(e: {
49
+ deltaY: number;
50
+ deltaMode?: number;
51
+ ctrlKey?: boolean;
52
+ }): void;
53
+ /** A touch pinch starts with the fingers `spread` apart (px); `pinch` follows it; `pinchEnd` ends it. */
54
+ pinchStart(spread: number): void;
55
+ pinch(spread: number): void;
56
+ pinchEnd(): void;
57
+ /** One frame: the distance eases to the one asked for (at once when `calm`, reduced motion). Returns it. */
58
+ step(dt: number, calm: boolean): number;
59
+ /** Calls `on` when the distance asked for changes. Returns the function that stops it. */
60
+ subscribe(on: () => void): () => void;
61
+ };
62
+ /**
63
+ * The camera's distance of one game. `read` gives the kept distance (null: none, the default `start`), and is read the first time the distance is
64
+ * asked for, so the server never calls it; `write` keeps a new one (on every change: put a delay in it if your storage is slow).
65
+ */
66
+ export declare function createZoom(r: ZoomRange, keep?: {
67
+ read?: () => number | null;
68
+ write?: (d: number) => void;
69
+ }): Zoom;
70
+ type Listens = Pick<EventTarget, "addEventListener" | "removeEventListener">;
71
+ /**
72
+ * The wheel over `el` (the game's canvas: the page does not scroll or zoom there) and the + and − keys on the window (also the number pad's) move
73
+ * `zoom`. A key with ctrl, alt or the command key is the browser's own zoom and is left alone, and so is a key typed in a field. `skip(e)` returns true
74
+ * for an event the game wants to keep from the zoom (a key the player is binding, a game that is paused). Returns the function that takes the listeners away.
75
+ * The touch pinch is yours to wire (`pinchStart`, `pinch`, `pinchEnd`), because the fingers are also your thumb stick and your look.
76
+ */
77
+ export declare function listenZoom(zoom: Zoom, el: Listens, opts?: {
78
+ skip?: (e: Event) => boolean;
79
+ keys?: boolean;
80
+ }, win?: Listens): () => void;
81
+ export {};