@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.
- package/CHANGELOG.md +37 -0
- package/README.md +110 -12
- package/ai/SPEC.md +11 -4
- package/dist/admin/guard.d.ts +9 -1
- package/dist/admin/guard.js +12 -4
- package/dist/admin/panel/card.d.ts +5 -0
- package/dist/admin/panel/card.js +3 -0
- package/dist/admin/panel/icons.d.ts +9 -0
- package/dist/admin/panel/icons.js +1 -0
- package/dist/admin/panel/index.d.ts +1 -1
- package/dist/admin/panel/mount.d.ts +7 -0
- package/dist/admin/panel/mount.js +4 -1
- package/dist/admin-signin/adapters/d1.d.ts +3 -1
- package/dist/admin-signin/adapters/d1.js +58 -3
- package/dist/admin-signin/adapters/firestore.d.ts +5 -2
- package/dist/admin-signin/adapters/firestore.js +74 -5
- package/dist/admin-signin/conformance.d.ts +8 -1
- package/dist/admin-signin/conformance.js +88 -3
- package/dist/admin-signin/handler.d.ts +7 -0
- package/dist/admin-signin/handler.js +134 -6
- package/dist/admin-signin/index.d.ts +2 -2
- package/dist/admin-signin/index.js +3 -2
- package/dist/admin-signin/memory.d.ts +6 -2
- package/dist/admin-signin/memory.js +47 -1
- package/dist/admin-signin/panel/index.d.ts +3 -0
- package/dist/admin-signin/panel/index.js +4 -2
- package/dist/admin-signin/panel/passkeys.d.ts +43 -0
- package/dist/admin-signin/panel/passkeys.js +202 -0
- package/dist/admin-signin/panel/view.d.ts +12 -0
- package/dist/admin-signin/panel/view.js +48 -1
- package/dist/admin-signin/types.d.ts +57 -7
- package/dist/admin-signin/types.js +20 -3
- package/dist/input/index.d.ts +2 -1
- package/dist/input/index.js +3 -0
- package/dist/input/zoom.d.ts +81 -0
- package/dist/input/zoom.js +140 -0
- package/package.json +1 -1
- package/src/admin/guard.ts +19 -5
- package/src/admin/panel/card.ts +5 -0
- package/src/admin/panel/icons.ts +1 -0
- package/src/admin/panel/index.ts +1 -1
- package/src/admin/panel/mount.ts +11 -1
- package/src/admin/panel/style.css +44 -0
- package/src/admin-signin/adapters/d1.sql +20 -3
- package/src/admin-signin/adapters/d1.ts +58 -5
- package/src/admin-signin/adapters/firestore.ts +73 -6
- package/src/admin-signin/conformance.ts +99 -3
- package/src/admin-signin/handler.ts +131 -4
- package/src/admin-signin/index.ts +7 -2
- package/src/admin-signin/memory.ts +43 -2
- package/src/admin-signin/panel/index.ts +6 -2
- package/src/admin-signin/panel/passkeys.ts +245 -0
- package/src/admin-signin/panel/view.ts +63 -1
- package/src/admin-signin/types.ts +62 -11
- package/src/input/index.ts +5 -0
- package/src/input/zoom.ts +160 -0
- package/src/player-signin/panel/style.css +3 -2
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The camera's distance for a game the player walks in: the mouse wheel, a trackpad's two-finger scroll or pinch, a two-finger pinch on a touch
|
|
2
|
+
// screen, and the + and − keys move one number, the camera's distance from the eyes, along ONE curve. 0 is first person; below `first` the camera
|
|
3
|
+
// is in the eyes. It eases to the distance asked for (at once with reduced motion). No package: plain browser events.
|
|
4
|
+
//
|
|
5
|
+
// THE FEEL is nano133.com's island (app/island/zoom.ts). Each notch moves the distance in proportion to it (`0.35 + 0.14 × distance` metres a
|
|
6
|
+
// notch), so a notch feels the same near and far. Every bit of a scroll counts: a dead zone and a speed limit dropped most of a quick spin in
|
|
7
|
+
// Echo Island, and its wheel needed about five times the spins of the island's (Wes, 2026-11-01). Close in, the last notch goes all the way to
|
|
8
|
+
// the eyes; out of the eyes, the first notch goes past the switch, so the camera never rests half inside the head.
|
|
9
|
+
//
|
|
10
|
+
// THE PINCH follows the same curve: the curve is a constant ratio of `distance + 2.5`, so spreading the fingers to twice their start divides it
|
|
11
|
+
// by 2^PINCH.power, wherever the camera is.
|
|
12
|
+
/** One notch: about one mouse-wheel click (100 px of scroll). The + and − keys move `key` notches. */
|
|
13
|
+
export const ZOOM_STEP = { perPx: 0.006, perPxPinch: 0.03, linePx: 16, pagePx: 400, key: 1.2 };
|
|
14
|
+
/** The curve: a notch moves `base + scale × distance` metres. */
|
|
15
|
+
export const ZOOM_CURVE = { base: 0.35, scale: 0.14 };
|
|
16
|
+
/** The touch pinch: twice the spread divides `distance + base/scale` by 2^power. */
|
|
17
|
+
export const PINCH = { power: 1.3 };
|
|
18
|
+
/** How fast the camera eases to the distance asked for (1/s). */
|
|
19
|
+
export const ZOOM_EASE = 9;
|
|
20
|
+
const clamp = (r, d) => Math.min(r.max, Math.max(0, d));
|
|
21
|
+
/** 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. */
|
|
22
|
+
export function wheelNotches(e) {
|
|
23
|
+
const px = e.deltaMode === 1 ? e.deltaY * ZOOM_STEP.linePx : e.deltaMode === 2 ? e.deltaY * ZOOM_STEP.pagePx : e.deltaY;
|
|
24
|
+
return px * (e.ctrlKey ? ZOOM_STEP.perPxPinch : ZOOM_STEP.perPx);
|
|
25
|
+
}
|
|
26
|
+
/** The snap: close in, a move into the switch goes to the eyes; out of the eyes, a move out goes past the switch. */
|
|
27
|
+
function snapped(r, from, to) {
|
|
28
|
+
if (to < from && to < r.first)
|
|
29
|
+
return 0;
|
|
30
|
+
if (to > from && from < r.first)
|
|
31
|
+
return clamp(r, Math.max(to, r.first + 1));
|
|
32
|
+
return clamp(r, to);
|
|
33
|
+
}
|
|
34
|
+
/** The distance after `notches` (negative: in) from `want`. */
|
|
35
|
+
export function zoomedBy(r, want, notches) {
|
|
36
|
+
return snapped(r, want, want + notches * (ZOOM_CURVE.base + want * ZOOM_CURVE.scale));
|
|
37
|
+
}
|
|
38
|
+
/** The distance during a touch pinch: `from` at the start, the fingers `spread0` apart then and `spread` now (px). Apart brings it in. */
|
|
39
|
+
export function pinchedTo(r, from, spread0, spread) {
|
|
40
|
+
if (!(spread0 > 0) || !(spread > 0))
|
|
41
|
+
return from;
|
|
42
|
+
const k = ZOOM_CURVE.base / ZOOM_CURVE.scale;
|
|
43
|
+
return snapped(r, from, (from + k) * (spread0 / spread) ** PINCH.power - k);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 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
|
|
47
|
+
* 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).
|
|
48
|
+
*/
|
|
49
|
+
export function createZoom(r, keep = {}) {
|
|
50
|
+
let want = r.start;
|
|
51
|
+
let now = r.start;
|
|
52
|
+
let loaded = false;
|
|
53
|
+
let pinchFrom = null;
|
|
54
|
+
const listeners = new Set();
|
|
55
|
+
const load = () => {
|
|
56
|
+
if (loaded)
|
|
57
|
+
return;
|
|
58
|
+
loaded = true;
|
|
59
|
+
const v = keep.read?.();
|
|
60
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
61
|
+
want = now = clamp(r, v);
|
|
62
|
+
};
|
|
63
|
+
const set = (d) => {
|
|
64
|
+
load();
|
|
65
|
+
want = clamp(r, d);
|
|
66
|
+
keep.write?.(want);
|
|
67
|
+
for (const on of listeners)
|
|
68
|
+
on();
|
|
69
|
+
};
|
|
70
|
+
const by = (notches) => {
|
|
71
|
+
load();
|
|
72
|
+
if (notches !== 0)
|
|
73
|
+
set(zoomedBy(r, want, notches));
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
want: () => (load(), want),
|
|
77
|
+
now: () => (load(), now),
|
|
78
|
+
eyes: () => (load(), now < r.first),
|
|
79
|
+
set,
|
|
80
|
+
by,
|
|
81
|
+
wheel: (e) => by(wheelNotches(e)),
|
|
82
|
+
pinchStart(spread) {
|
|
83
|
+
load();
|
|
84
|
+
pinchFrom = { d: want, spread };
|
|
85
|
+
},
|
|
86
|
+
pinch(spread) {
|
|
87
|
+
if (pinchFrom)
|
|
88
|
+
set(pinchedTo(r, pinchFrom.d, pinchFrom.spread, spread));
|
|
89
|
+
},
|
|
90
|
+
pinchEnd() {
|
|
91
|
+
pinchFrom = null;
|
|
92
|
+
},
|
|
93
|
+
step(dt, calm) {
|
|
94
|
+
load();
|
|
95
|
+
now = calm ? want : now + (want - now) * (1 - Math.exp(-dt * ZOOM_EASE));
|
|
96
|
+
if (Math.abs(now - want) < 0.002)
|
|
97
|
+
now = want;
|
|
98
|
+
return now;
|
|
99
|
+
},
|
|
100
|
+
subscribe(on) {
|
|
101
|
+
listeners.add(on);
|
|
102
|
+
return () => void listeners.delete(on);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 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
|
|
108
|
+
* `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
|
|
109
|
+
* 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.
|
|
110
|
+
* The touch pinch is yours to wire (`pinchStart`, `pinch`, `pinchEnd`), because the fingers are also your thumb stick and your look.
|
|
111
|
+
*/
|
|
112
|
+
export function listenZoom(zoom, el, opts = {}, win = window) {
|
|
113
|
+
const wheel = (e) => {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
if (opts.skip?.(e))
|
|
116
|
+
return;
|
|
117
|
+
zoom.wheel(e);
|
|
118
|
+
};
|
|
119
|
+
const key = (e) => {
|
|
120
|
+
const k = e;
|
|
121
|
+
if (k.metaKey || k.altKey || k.ctrlKey || opts.skip?.(e))
|
|
122
|
+
return;
|
|
123
|
+
const t = k.target;
|
|
124
|
+
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName ?? "")))
|
|
125
|
+
return;
|
|
126
|
+
if (k.code === "Equal" || k.code === "NumpadAdd")
|
|
127
|
+
zoom.by(-ZOOM_STEP.key);
|
|
128
|
+
else if (k.code === "Minus" || k.code === "NumpadSubtract")
|
|
129
|
+
zoom.by(ZOOM_STEP.key);
|
|
130
|
+
};
|
|
131
|
+
el.addEventListener("wheel", wheel, { passive: false });
|
|
132
|
+
const keys = opts.keys !== false;
|
|
133
|
+
if (keys)
|
|
134
|
+
win.addEventListener("keydown", key);
|
|
135
|
+
return () => {
|
|
136
|
+
el.removeEventListener("wheel", wheel);
|
|
137
|
+
if (keys)
|
|
138
|
+
win.removeEventListener("keydown", key);
|
|
139
|
+
};
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano133/nanoterra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton), the looks ladder, the admin and its wallet sign-in, the friends list, an options menu, emotes, the player's sign-in (a passkey or a wallet app) and a shop that sells looks for Nano with no key on the server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
package/src/admin/guard.ts
CHANGED
|
@@ -4,13 +4,22 @@
|
|
|
4
4
|
// DO NOT LOOSEN THIS. It is the game starter's `adminGuard` (src/worker/admin.ts), with the same five checks in the same order, and it may only
|
|
5
5
|
// refuse MORE than the starter does, never less (a test runs both over a grid of inputs and proves it). Deny by default: anything this function
|
|
6
6
|
// does not understand is a refusal, and a fault inside it is a refusal too.
|
|
7
|
+
// The ONE rule Wes approved on 2026-11-01 (the owner's linked passkey): a "passkey" session opens /admin only with `linkedTo` === the owner (checks 3 and 4).
|
|
7
8
|
|
|
8
9
|
/** An admin login a game has found for this request: the address that signed, when the login was made (ms), and how it was made. */
|
|
9
10
|
export type AdminSession = {
|
|
10
11
|
address: string;
|
|
11
12
|
createdAt: number;
|
|
12
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* "wallet" is a signature made with the address's key. "passkey" is a signature by a passkey that the owner linked: it counts only with `linkedTo`.
|
|
15
|
+
* Anything else (a sign-in from Nanoterra names an address but proves no key) never opens /admin.
|
|
16
|
+
*/
|
|
13
17
|
via: string;
|
|
18
|
+
/**
|
|
19
|
+
* For a "passkey" session only: the owner address its link points to, set by the sign-in only after it read the link on THIS request. The guard compares
|
|
20
|
+
* it with the owner instead of `address` (the passkey's own). A session of any other `via` is judged by its `address`; this field is not read.
|
|
21
|
+
*/
|
|
22
|
+
linkedTo?: string;
|
|
14
23
|
};
|
|
15
24
|
|
|
16
25
|
export type AdminAccessInput = {
|
|
@@ -75,10 +84,15 @@ function decide(input: AdminAccessInput): AdminAccess {
|
|
|
75
84
|
if (!session || typeof session !== "object") return refuse(401, "Sign in with your Nano address.");
|
|
76
85
|
const { address, createdAt, via } = session;
|
|
77
86
|
if (typeof address !== "string" || typeof via !== "string" || typeof createdAt !== "number") return refuse(401, "Sign in with your Nano address.");
|
|
78
|
-
// 3. It was made with a wallet signature
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
87
|
+
// 3. It was made with a wallet signature, or (the one rule of 2026-11-01) by a passkey whose link the sign-in read on this request. A sign-in from
|
|
88
|
+
// Nanoterra names an address but proves no key.
|
|
89
|
+
let linkedTo: unknown;
|
|
90
|
+
if (via === "passkey") {
|
|
91
|
+
linkedTo = session.linkedTo;
|
|
92
|
+
if (typeof linkedTo !== "string") return refuse(403, "Sign in with your Nano wallet to open this page.", { reason: "nanoterra" });
|
|
93
|
+
} else if (via !== "wallet") return refuse(403, "Sign in with your Nano wallet to open this page.", { reason: "nanoterra" });
|
|
94
|
+
// 4. It is the owner's address (the same text, no other form): a wallet's own address, or the address a passkey's link points to.
|
|
95
|
+
if ((via === "passkey" ? linkedTo : address) !== owner) return refuse(403, "Only the game's own address can open this page.", { reason: "other_address", signedInAs: address });
|
|
82
96
|
// 5. It is not older than the limit (one day at most), and not from the future.
|
|
83
97
|
const age = now - createdAt;
|
|
84
98
|
if (!(age >= -CLOCK_SKEW_MS && age <= max)) return refuse(401, "Sign in again: an admin sign-in counts for one day.");
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// A card of the game's own for the admin panel's Settings tab (`mountAdmin`'s `cards`). Its own file, so a card made elsewhere (the owner's passkeys, in
|
|
2
|
+
// "@nano133/nanoterra/admin-signin/panel") names the type without reaching the whole panel.
|
|
3
|
+
|
|
4
|
+
/** A card of the game's own in the Settings tab: its element, made once, and what to do each time the owner signs in (read its data again). */
|
|
5
|
+
export type AdminCard = { el: HTMLElement; refresh?: () => void | Promise<void> };
|
package/src/admin/panel/icons.ts
CHANGED
|
@@ -35,6 +35,7 @@ const SHAPES = {
|
|
|
35
35
|
gear: [["path", { d: "M19.23 9.65 21.77 9.86V14.14L19.23 14.35 18.77 15.45 20.42 17.39 17.39 20.42 15.45 18.77 14.35 19.23 14.14 21.77H9.86L9.65 19.23 8.55 18.77 6.61 20.42 3.58 17.39 5.23 15.45 4.77 14.35 2.23 14.14V9.86L4.77 9.65 5.23 8.55 3.58 6.61 6.61 3.58 8.55 5.23 9.65 4.77 9.86 2.23H14.14L14.35 4.77 15.45 5.23 17.39 3.58 20.42 6.61 18.77 8.55Z" }], ["circle", { cx: 12, cy: 12, r: 3.2 }]],
|
|
36
36
|
sparkle: [["path", { d: "M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6" }]],
|
|
37
37
|
smile: [["circle", { cx: 12, cy: 12, r: 9 }], ["path", { d: "M8.2 14.2a4.6 4.6 0 0 0 7.6 0" }], ["path", { d: "M9 9.6h.01M15 9.6h.01", "stroke-width": 2.6 }]],
|
|
38
|
+
key: [["path", { d: "M14.5 9.5a4.5 4.5 0 1 1-1.3-3.2" }], ["path", { d: "M13.2 6.3 21 6.3M18 6.3v3M21 6.3v2.2" }], ["circle", { cx: 10, cy: 9.5, r: 1.4, fill: "currentColor", stroke: "none" }]],
|
|
38
39
|
} satisfies Record<string, Shape[]>;
|
|
39
40
|
|
|
40
41
|
export type IconName = keyof typeof SHAPES;
|
package/src/admin/panel/index.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// The server part is "@nano133/nanoterra/admin" (the guard and the handler): it must not load this one.
|
|
8
8
|
|
|
9
9
|
export { mountAdmin } from "./mount.js";
|
|
10
|
-
export type { MountedAdmin, MountOptions } from "./mount.js";
|
|
10
|
+
export type { AdminCard, MountedAdmin, MountOptions } from "./mount.js";
|
|
11
11
|
export { ApiError, createApi } from "./api.js";
|
|
12
12
|
export type { Api } from "./api.js";
|
|
13
13
|
export { gameCard } from "./gameCard.js";
|
package/src/admin/panel/mount.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { AdminSummary } from "../store.js";
|
|
|
10
10
|
import type { GameState } from "../switches.js";
|
|
11
11
|
import { PASSKEY_PREVIEW_NOTE, previewHost } from "../../player-signin/host.js";
|
|
12
12
|
import { ApiError, createApi, type Api } from "./api.js";
|
|
13
|
+
import type { AdminCard } from "./card.js";
|
|
13
14
|
import { copyKey, h, plural, shortAddress } from "./dom.js";
|
|
14
15
|
import { gameCard } from "./gameCard.js";
|
|
15
16
|
import { icon, type IconName } from "./icons.js";
|
|
@@ -43,10 +44,17 @@ export type MountOptions = {
|
|
|
43
44
|
/** The game offers its players a passkey (`@nano133/nanoterra/player-signin`): on a preview address (`*.workers.dev`) the banner says that passkeys turn
|
|
44
45
|
* on with the game's own domain. */
|
|
45
46
|
playerPasskeys?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Cards of the game's own for the Settings tab, after the Sign-ins card: for example the owner's passkeys (`ownerPasskeys()` of
|
|
49
|
+
* "@nano133/nanoterra/admin-signin/panel"). Each card is made once and kept for the life of the page; its `refresh()` runs each time the owner signs in.
|
|
50
|
+
*/
|
|
51
|
+
cards?: readonly AdminCard[];
|
|
46
52
|
/** The `fetch` to use (default: the page's). */
|
|
47
53
|
fetch?: typeof fetch;
|
|
48
54
|
};
|
|
49
55
|
|
|
56
|
+
export type { AdminCard };
|
|
57
|
+
|
|
50
58
|
export type MountedAdmin = {
|
|
51
59
|
/** Reads everything again and redraws. */
|
|
52
60
|
reload(): Promise<void>;
|
|
@@ -193,6 +201,8 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
|
|
|
193
201
|
const kpis = h("div", { class: "kpis", role: "group", "aria-label": "Your game in numbers" });
|
|
194
202
|
const settings = h("div", { class: "settings-grid" });
|
|
195
203
|
tabs.panels.settings.append(settings);
|
|
204
|
+
// The game's own cards read their data again at each sign-in (a new dashboard is built after every sign-in).
|
|
205
|
+
for (const c of options.cards ?? []) void Promise.resolve(c.refresh?.()).catch(() => undefined);
|
|
196
206
|
data.replaceChildren(top, banner, kpis, tabs.bar, ...Object.values(tabs.panels));
|
|
197
207
|
|
|
198
208
|
const kpi = (cls: string, mark: IconName, label: string, value: string, sub: string, open: () => void, alert = false) =>
|
|
@@ -245,7 +255,7 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
|
|
|
245
255
|
orders.update(s);
|
|
246
256
|
players.update(s);
|
|
247
257
|
// The Game card is the same element at every redraw: moving it keeps its state.
|
|
248
|
-
settings.replaceChildren(...[game, signinsCard(s, api, () => void load(), options.loginsNote), about(s)].filter((p): p is HTMLElement => p !== null));
|
|
258
|
+
settings.replaceChildren(...[game, signinsCard(s, api, () => void load(), options.loginsNote), ...(options.cards ?? []).map((c) => c.el), about(s)].filter((p): p is HTMLElement => p !== null));
|
|
249
259
|
},
|
|
250
260
|
};
|
|
251
261
|
}
|
|
@@ -1968,6 +1968,50 @@
|
|
|
1968
1968
|
flex-basis: 100%;
|
|
1969
1969
|
}
|
|
1970
1970
|
|
|
1971
|
+
/* ---- The owner's passkeys (a card from "@nano133/nanoterra/admin-signin/panel"): phone first, 16 px text, 48 px keys ---- */
|
|
1972
|
+
|
|
1973
|
+
.n133-admin .owner-passkeys .btn {
|
|
1974
|
+
min-height: 48px;
|
|
1975
|
+
font-size: 16px;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
.n133-admin .passkey-row {
|
|
1979
|
+
display: flex;
|
|
1980
|
+
flex-wrap: wrap;
|
|
1981
|
+
align-items: center;
|
|
1982
|
+
justify-content: space-between;
|
|
1983
|
+
gap: 8px;
|
|
1984
|
+
font-size: 16px;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
.n133-admin .passkey-what {
|
|
1988
|
+
display: inline-flex;
|
|
1989
|
+
flex-wrap: wrap;
|
|
1990
|
+
align-items: center;
|
|
1991
|
+
gap: 6px;
|
|
1992
|
+
min-width: 0;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
.n133-admin .passkey-add,
|
|
1996
|
+
.n133-admin .passkey-confirm {
|
|
1997
|
+
display: grid;
|
|
1998
|
+
gap: 8px;
|
|
1999
|
+
justify-items: start;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
.n133-admin .passkey-confirm {
|
|
2003
|
+
flex-basis: 100%;
|
|
2004
|
+
padding: 12px;
|
|
2005
|
+
border-radius: 12px;
|
|
2006
|
+
background: var(--alarm-bg);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
.n133-admin .passkey-keys {
|
|
2010
|
+
display: flex;
|
|
2011
|
+
flex-wrap: wrap;
|
|
2012
|
+
gap: 8px;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
1971
2015
|
/* ---- Wider panels ---- */
|
|
1972
2016
|
|
|
1973
2017
|
@container n133 (min-width: 560px) {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
-- The admin sign-in tables of "@nano133/nanoterra/admin-signin/d1". Add this file to the game's migrations (for example
|
|
2
|
-
-- migrations/0009_admin_signin.sql) and run `wrangler d1 migrations apply`. The adapter reads and writes only these
|
|
2
|
+
-- migrations/0009_admin_signin.sql) and run `wrangler d1 migrations apply`. The adapter reads and writes only these five tables, and only the
|
|
3
3
|
-- ADMIN rows of the first two (scope = 'admin').
|
|
4
4
|
--
|
|
5
|
-
-- The game starter has
|
|
6
|
-
--
|
|
5
|
+
-- The game starter has the first three already (migrations 0001, 0002, 0003, 0005 and 0006), and `IF NOT EXISTS` leaves its tables and rows as they are if
|
|
6
|
+
-- it runs this file anyway. The last two (since 0.12.0) are the owner's passkeys: a game that turns them on (`createAdminSignin({ passkeys: true })`) and
|
|
7
|
+
-- has the first three adds only those two (the starter: migrations/0009_admin_passkeys.sql, the two CREATE TABLE statements at the end of this file). A
|
|
8
|
+
-- game that has the player sign-in's d1.sql has the first two: this file adds the other three.
|
|
7
9
|
-- Times are milliseconds since 1970 (Date.now()).
|
|
8
10
|
|
|
9
11
|
-- Wallet sign-in requests (the player's and the admin's: `scope` says whose). An admin request lives 2 minutes; the clean-up deletes it 2 hours after it ends.
|
|
@@ -43,3 +45,18 @@ CREATE TABLE IF NOT EXISTS admin_logins (
|
|
|
43
45
|
browser TEXT NOT NULL -- a short name from fixed lists: "Chrome on macOS"
|
|
44
46
|
);
|
|
45
47
|
CREATE INDEX IF NOT EXISTS admin_logins_by_time ON admin_logins (at);
|
|
48
|
+
|
|
49
|
+
-- The owner's passkeys (since 0.12.0): each passkey linked to the game's own address, at most 10 for an owner. Only a live WALLET admin session of the owner
|
|
50
|
+
-- writes a link; a link opens /admin only while it is here and its owner is the game's address NOW.
|
|
51
|
+
CREATE TABLE IF NOT EXISTS admin_passkeys (
|
|
52
|
+
owner TEXT NOT NULL, -- the game's own address when the link was made
|
|
53
|
+
address TEXT NOT NULL, -- the passkey purse's address
|
|
54
|
+
created_at INTEGER NOT NULL, -- ms since 1970
|
|
55
|
+
PRIMARY KEY (owner, address)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
-- The passkeys' used signatures (the admin's own, apart from the player's signin_once): the hash of each signed text, so one signature counts once.
|
|
59
|
+
CREATE TABLE IF NOT EXISTS admin_once (
|
|
60
|
+
key TEXT PRIMARY KEY,
|
|
61
|
+
until INTEGER NOT NULL -- ms since 1970; the clean-up deletes the row after it
|
|
62
|
+
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The admin sign-in's store for a game on Cloudflare D1, on the game starter's OWN tables and columns: `signin_requests` and `sessions` (their admin
|
|
2
2
|
// rows, scope = 'admin') and `admin_logins`. A starter moves onto it with no migration, and every creator who is signed in to /admin stays signed in.
|
|
3
|
-
// A new game adds ./d1.sql to its migrations ("@nano133/nanoterra/admin-signin/d1.sql": the same tables, `IF NOT EXISTS`).
|
|
3
|
+
// A new game adds ./d1.sql to its migrations ("@nano133/nanoterra/admin-signin/d1.sql": the same tables, `IF NOT EXISTS`). The owner's passkeys (since
|
|
4
|
+
// 0.12.0) use two more tables, `admin_passkeys` and `admin_once` (the end of d1.sql): only a game that turns the passkeys on needs them.
|
|
4
5
|
//
|
|
5
6
|
// import { createD1AdminSignin } from "@nano133/nanoterra/admin-signin/d1";
|
|
6
7
|
// const store = createD1AdminSignin(env.DB);
|
|
@@ -11,12 +12,12 @@
|
|
|
11
12
|
// "Sign out everywhere" is the same two statements as the admin store's (`createD1Store(db, { logins: true })`): either one may run it.
|
|
12
13
|
|
|
13
14
|
import type { D1Like } from "../../admin/adapters/d1.js";
|
|
14
|
-
import { ADMIN_LOGIN_KEEP_MS, ADMIN_WAYS, type AdminSessionRecord, type AdminSigninStore, type AdminWay, type RequestStoreLike, type SigninRequest } from "../types.js";
|
|
15
|
+
import { ADMIN_LOGIN_KEEP_MS, ADMIN_PASSKEY_LIMIT, ADMIN_WAYS, type AdminLinkResult, type AdminSessionRecord, type AdminSigninStore, type AdminWay, type RequestStoreLike, type SigninRequest } from "../types.js";
|
|
15
16
|
|
|
16
17
|
export type { D1Like, D1StatementLike } from "../../admin/adapters/d1.js";
|
|
17
18
|
|
|
18
19
|
/** The tables' names. Default: the game starter's. A game with other names changes d1.sql to match. */
|
|
19
|
-
export type D1AdminSigninTables = { requests?: string; sessions?: string; logins?: string };
|
|
20
|
+
export type D1AdminSigninTables = { requests?: string; sessions?: string; logins?: string; passkeys?: string; once?: string };
|
|
20
21
|
|
|
21
22
|
type Row = { id: string; rid: string | null; starter: string; status: string; expires_at: number; nonce: string | null; issued_at: number | null; label: string | null; account: string | null };
|
|
22
23
|
|
|
@@ -34,8 +35,17 @@ const toRequest = (r: Row): SigninRequest => ({
|
|
|
34
35
|
const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
|
|
35
36
|
const changes = (res: unknown): number => Number((res as { meta?: { changes?: number } } | undefined)?.meta?.changes ?? 0);
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
/** True for D1's (and SQLite's) "no such table": a game that has not added the passkeys' tables. */
|
|
39
|
+
const noTable = (e: unknown) => /no such table/i.test(String((e as { message?: unknown } | null)?.message ?? e));
|
|
40
|
+
|
|
41
|
+
export function createD1AdminSignin(db: D1Like, tables: D1AdminSigninTables = {}): Required<AdminSigninStore> {
|
|
42
|
+
const T = {
|
|
43
|
+
requests: tables.requests ?? "signin_requests",
|
|
44
|
+
sessions: tables.sessions ?? "sessions",
|
|
45
|
+
logins: tables.logins ?? "admin_logins",
|
|
46
|
+
passkeys: tables.passkeys ?? "admin_passkeys",
|
|
47
|
+
once: tables.once ?? "admin_once",
|
|
48
|
+
};
|
|
39
49
|
for (const n of Object.values(T)) if (!NAME.test(n)) throw new Error(`not a table name: ${n}`);
|
|
40
50
|
const COLS = "id, rid, starter, status, expires_at, nonce, issued_at, label, account";
|
|
41
51
|
|
|
@@ -112,6 +122,49 @@ export function createD1AdminSignin(db: D1Like, tables: D1AdminSigninTables = {}
|
|
|
112
122
|
db.prepare(`DELETE FROM ${T.sessions} WHERE expires_at < ? AND scope = 'admin'`).bind(now),
|
|
113
123
|
db.prepare(`DELETE FROM ${T.logins} WHERE at < ?`).bind(now - ADMIN_LOGIN_KEEP_MS),
|
|
114
124
|
]);
|
|
125
|
+
// The used passkey signatures, on their own: a game without the passkeys' tables (they came in 0.12.0) still cleans the rest.
|
|
126
|
+
try {
|
|
127
|
+
await db.prepare(`DELETE FROM ${T.once} WHERE until <= ?`).bind(now).run();
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (!noTable(e)) throw e;
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
async createSession(s) {
|
|
133
|
+
await db.prepare(`INSERT INTO ${T.sessions} (id_hash, address, created_at, expires_at, via, scope) VALUES (?, ?, ?, ?, ?, 'admin')`).bind(s.idHash, s.address, s.createdAt, s.expiresAt, s.via).run();
|
|
134
|
+
},
|
|
135
|
+
async linkPasskey(owner, address, at): Promise<AdminLinkResult> {
|
|
136
|
+
// ONE statement holds the limit: the row is written only while the owner has fewer than the limit, and never twice.
|
|
137
|
+
const res = await db
|
|
138
|
+
.prepare(`INSERT INTO ${T.passkeys} (owner, address, created_at) SELECT ?, ?, ? WHERE (SELECT COUNT(*) FROM ${T.passkeys} WHERE owner = ?) < ? ON CONFLICT (owner, address) DO NOTHING`)
|
|
139
|
+
.bind(owner, address, at, owner, ADMIN_PASSKEY_LIMIT)
|
|
140
|
+
.run();
|
|
141
|
+
if (changes(res) === 1) return "linked";
|
|
142
|
+
const had = await db.prepare(`SELECT 1 AS one FROM ${T.passkeys} WHERE owner = ? AND address = ?`).bind(owner, address).first<{ one: number }>();
|
|
143
|
+
return had ? "exists" : "full";
|
|
144
|
+
},
|
|
145
|
+
async unlinkPasskey(owner, address) {
|
|
146
|
+
// The link, and the passkey's admin sessions with it.
|
|
147
|
+
const done = await db.batch([
|
|
148
|
+
db.prepare(`DELETE FROM ${T.passkeys} WHERE owner = ? AND address = ?`).bind(owner, address),
|
|
149
|
+
db.prepare(`DELETE FROM ${T.sessions} WHERE scope = 'admin' AND via = 'passkey' AND address = ?`).bind(address),
|
|
150
|
+
]);
|
|
151
|
+
return changes(done[0]) > 0;
|
|
152
|
+
},
|
|
153
|
+
async passkeys(owner) {
|
|
154
|
+
const rows = (await db.prepare(`SELECT address, created_at FROM ${T.passkeys} WHERE owner = ? ORDER BY created_at, address`).bind(owner).all<{ address: string; created_at: number }>()).results;
|
|
155
|
+
return rows.map((r) => ({ address: String(r.address), createdAt: Number(r.created_at) }));
|
|
156
|
+
},
|
|
157
|
+
async linked(owner, address) {
|
|
158
|
+
return (await db.prepare(`SELECT 1 AS one FROM ${T.passkeys} WHERE owner = ? AND address = ?`).bind(owner, address).first<{ one: number }>()) !== null;
|
|
159
|
+
},
|
|
160
|
+
async useOnce(key, ttlMs) {
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
// A row past its time may be taken again (its signature's window is long over); a live one may not.
|
|
163
|
+
const res = await db
|
|
164
|
+
.prepare(`INSERT INTO ${T.once} (key, until) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET until = excluded.until WHERE ${T.once}.until <= ?`)
|
|
165
|
+
.bind(key, now + ttlMs, now)
|
|
166
|
+
.run();
|
|
167
|
+
return changes(res) === 1;
|
|
115
168
|
},
|
|
116
169
|
};
|
|
117
170
|
}
|
|
@@ -9,15 +9,18 @@
|
|
|
9
9
|
// signin_requests/{key} { id, way, starter, status, expiresAt, nonce?, issuedAt?, label?, account?, delivered, scope: "admin", deleteAt }
|
|
10
10
|
// sessions/{idHash} { address, createdAt, expiresAt, via, scope: "admin", deleteAt }
|
|
11
11
|
// admin_logins/{id} { at, country, browser, deleteAt }
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// admin_passkeys/{owner} { owner, passkeys: [{ address, createdAt }] } (since 0.12.0: the owner's linked passkeys, at most 10; no deleteAt)
|
|
13
|
+
// admin_once/{key} { until, deleteAt } (since 0.12.0: the passkeys' used signatures)
|
|
14
|
+
// Times are ms numbers; `deleteAt` is a Date (a Timestamp in Firestore): set a TTL policy on `deleteAt` for each collection that has it. `{key}` is the
|
|
15
|
+
// request's id; `{idHash}` the SHA-256 of "admin:" and the session's token. Every change that reads first is a transaction (the owner's passkeys are ONE
|
|
16
|
+
// document, so a link holds the limit in its transaction). Indexes: Firestore's own single-field indexes are enough (`id`, `scope`, `expiresAt`, `at`,
|
|
17
|
+
// `address`, `until`).
|
|
15
18
|
//
|
|
16
19
|
// The admin store for Firestore (`createFirestoreStore`) has no sign-in list and no "Sign out everywhere": give it this sign-in's,
|
|
17
20
|
// `store.recentLogins = signin.recentLogins` and `store.signOutEverywhere = signin.signOutEverywhere`.
|
|
18
21
|
|
|
19
22
|
import type { AdminLogin } from "../../admin/store.js";
|
|
20
|
-
import { ADMIN_LOGIN_KEEP_MS, ADMIN_WAYS, type AdminSessionRecord, type AdminSigninStore, type AdminWay, type RequestStoreLike, type SigninRequest } from "../types.js";
|
|
23
|
+
import { ADMIN_LOGIN_KEEP_MS, ADMIN_PASSKEY_LIMIT, ADMIN_WAYS, type AdminPasskey, type AdminSessionRecord, type AdminSigninStore, type AdminWay, type RequestStoreLike, type SigninRequest } from "../types.js";
|
|
21
24
|
|
|
22
25
|
export type AdminSigninDocSnap = { exists: boolean; data(): Record<string, unknown> | undefined };
|
|
23
26
|
export interface AdminSigninDocRef {
|
|
@@ -76,10 +79,24 @@ function requestOf(d: Record<string, unknown> | undefined, key: string, way: Adm
|
|
|
76
79
|
};
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
|
|
82
|
+
/** The links a passkeys document holds: only well-formed ones. */
|
|
83
|
+
function linksOf(d: Record<string, unknown> | undefined): AdminPasskey[] {
|
|
84
|
+
const list = Array.isArray(d?.passkeys) ? (d.passkeys as unknown[]) : [];
|
|
85
|
+
return list.flatMap((p) => {
|
|
86
|
+
const v = p as Record<string, unknown> | null;
|
|
87
|
+
const address = text(v?.address);
|
|
88
|
+
return address !== undefined ? [{ address, createdAt: num(v?.createdAt) ?? 0 }] : [];
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type FirestoreAdminSigninCollections = { requests?: string; sessions?: string; logins?: string; passkeys?: string; once?: string };
|
|
93
|
+
|
|
94
|
+
export function createFirestoreAdminSignin(db: FirestoreAdminSigninLike, collections: FirestoreAdminSigninCollections = {}): Required<AdminSigninStore> {
|
|
80
95
|
const requestsCol = () => db.collection(collections.requests ?? "signin_requests");
|
|
81
96
|
const sessionsCol = () => db.collection(collections.sessions ?? "sessions");
|
|
82
97
|
const loginsCol = () => db.collection(collections.logins ?? "admin_logins");
|
|
98
|
+
const passkeysCol = () => db.collection(collections.passkeys ?? "admin_passkeys");
|
|
99
|
+
const onceCol = () => db.collection(collections.once ?? "admin_once");
|
|
83
100
|
|
|
84
101
|
const sessionData = (s: AdminSessionRecord) => ({ address: s.address, createdAt: s.createdAt, expiresAt: s.expiresAt, via: s.via, scope: "admin", deleteAt: new Date(s.expiresAt) });
|
|
85
102
|
|
|
@@ -201,7 +218,57 @@ export function createFirestoreAdminSignin(db: FirestoreAdminSigninLike, collect
|
|
|
201
218
|
const old = (await requestsCol().where("expiresAt", "<", now - 2 * 3_600_000).limit(BATCH).get()).docs.filter((d) => d.data().scope === "admin");
|
|
202
219
|
const ended = (await sessionsCol().where("expiresAt", "<", now).limit(BATCH).get()).docs.filter((d) => d.data().scope === "admin");
|
|
203
220
|
const logins = (await loginsCol().where("at", "<", now - ADMIN_LOGIN_KEEP_MS).limit(BATCH).get()).docs;
|
|
204
|
-
await
|
|
221
|
+
const used = (await onceCol().where("until", "<", now + 1).limit(BATCH).get()).docs;
|
|
222
|
+
await deleteAll([...old, ...ended, ...logins, ...used]);
|
|
223
|
+
},
|
|
224
|
+
async createSession(session) {
|
|
225
|
+
const ref = sessionsCol().doc(session.idHash);
|
|
226
|
+
await db.runTransaction(async (tx) => {
|
|
227
|
+
if ((await tx.get(ref)).exists) throw new Error("a session with this id exists");
|
|
228
|
+
tx.set(ref, sessionData(session));
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
async linkPasskey(owner, address, at) {
|
|
232
|
+
const ref = passkeysCol().doc(owner);
|
|
233
|
+
return db.runTransaction(async (tx) => {
|
|
234
|
+
const d = (await tx.get(ref)).data();
|
|
235
|
+
const list = d && d.owner === owner ? linksOf(d) : [];
|
|
236
|
+
if (list.some((p) => p.address === address)) return "exists" as const;
|
|
237
|
+
if (list.length >= ADMIN_PASSKEY_LIMIT) return "full" as const;
|
|
238
|
+
tx.set(ref, { owner, passkeys: [...list, { address, createdAt: at }] });
|
|
239
|
+
return "linked" as const;
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
async unlinkPasskey(owner, address) {
|
|
243
|
+
const ref = passkeysCol().doc(owner);
|
|
244
|
+
const had = await db.runTransaction(async (tx) => {
|
|
245
|
+
const d = (await tx.get(ref)).data();
|
|
246
|
+
const list = d && d.owner === owner ? linksOf(d) : [];
|
|
247
|
+
if (!list.some((p) => p.address === address)) return false;
|
|
248
|
+
tx.set(ref, { owner, passkeys: list.filter((p) => p.address !== address) });
|
|
249
|
+
return true;
|
|
250
|
+
});
|
|
251
|
+
// The passkey's admin sessions end with its link (after it: a session read in between finds no link and is refused anyway).
|
|
252
|
+
await deleteAll((await sessionsCol().where("address", "==", address).get()).docs.filter((x) => x.data().scope === "admin" && x.data().via === "passkey"));
|
|
253
|
+
return had;
|
|
254
|
+
},
|
|
255
|
+
async passkeys(owner) {
|
|
256
|
+
const d = (await passkeysCol().doc(owner).get()).data();
|
|
257
|
+
return d && d.owner === owner ? linksOf(d) : [];
|
|
258
|
+
},
|
|
259
|
+
async linked(owner, address) {
|
|
260
|
+
const d = (await passkeysCol().doc(owner).get()).data();
|
|
261
|
+
return Boolean(d && d.owner === owner && linksOf(d).some((p) => p.address === address));
|
|
262
|
+
},
|
|
263
|
+
async useOnce(key, ttlMs) {
|
|
264
|
+
const ref = onceCol().doc(key);
|
|
265
|
+
return db.runTransaction(async (tx) => {
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
const d = (await tx.get(ref)).data();
|
|
268
|
+
if (d && (num(d.until) ?? Infinity) > now) return false;
|
|
269
|
+
tx.set(ref, { until: now + ttlMs, deleteAt: new Date(now + ttlMs) });
|
|
270
|
+
return true;
|
|
271
|
+
});
|
|
205
272
|
},
|
|
206
273
|
};
|
|
207
274
|
}
|