@nano133/nanoterra 0.8.0 → 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.
Files changed (132) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/LICENSE +3 -0
  3. package/README.md +363 -25
  4. package/ai/SPEC.md +65 -3
  5. package/dist/admin/panel/dom.d.ts +2 -0
  6. package/dist/admin/panel/dom.js +6 -0
  7. package/dist/admin/panel/mount.d.ts +3 -0
  8. package/dist/admin/panel/mount.js +4 -2
  9. package/dist/emotes/panel/mount.d.ts +5 -0
  10. package/dist/emotes/panel/mount.js +7 -3
  11. package/dist/friends/panel/mount.d.ts +5 -0
  12. package/dist/friends/panel/mount.js +5 -2
  13. package/dist/options/panel/mount.d.ts +5 -0
  14. package/dist/options/panel/mount.js +5 -3
  15. package/dist/player-signin/adapters/d1.d.ts +10 -0
  16. package/dist/player-signin/adapters/d1.js +108 -0
  17. package/dist/player-signin/adapters/firestore.d.ts +45 -0
  18. package/dist/player-signin/adapters/firestore.js +168 -0
  19. package/dist/player-signin/conformance.d.ts +11 -0
  20. package/dist/player-signin/conformance.js +160 -0
  21. package/dist/player-signin/handler.d.ts +64 -0
  22. package/dist/player-signin/handler.js +364 -0
  23. package/dist/player-signin/host.d.ts +4 -0
  24. package/dist/player-signin/host.js +10 -0
  25. package/dist/player-signin/index.d.ts +9 -0
  26. package/dist/player-signin/index.js +15 -0
  27. package/dist/player-signin/memory.d.ts +15 -0
  28. package/dist/player-signin/memory.js +101 -0
  29. package/dist/player-signin/panel/index.d.ts +9 -0
  30. package/dist/player-signin/panel/index.js +7 -0
  31. package/dist/player-signin/panel/memo.d.ts +12 -0
  32. package/dist/player-signin/panel/memo.js +41 -0
  33. package/dist/player-signin/panel/mount.d.ts +21 -0
  34. package/dist/player-signin/panel/mount.js +105 -0
  35. package/dist/player-signin/panel/view.d.ts +45 -0
  36. package/dist/player-signin/panel/view.js +360 -0
  37. package/dist/player-signin/panel/words.d.ts +8 -0
  38. package/dist/player-signin/panel/words.js +26 -0
  39. package/dist/player-signin/scheme-check.d.ts +3 -0
  40. package/dist/player-signin/scheme-check.js +14 -0
  41. package/dist/player-signin/scheme.d.ts +13 -0
  42. package/dist/player-signin/scheme.js +28 -0
  43. package/dist/player-signin/types.d.ts +116 -0
  44. package/dist/player-signin/types.js +33 -0
  45. package/dist/shop/adapters/d1.d.ts +25 -0
  46. package/dist/shop/adapters/d1.js +200 -0
  47. package/dist/shop/adapters/firestore.d.ts +53 -0
  48. package/dist/shop/adapters/firestore.js +245 -0
  49. package/dist/shop/address.d.ts +14 -0
  50. package/dist/shop/address.js +104 -0
  51. package/dist/shop/amount.d.ts +26 -0
  52. package/dist/shop/amount.js +59 -0
  53. package/dist/shop/conformance.d.ts +13 -0
  54. package/dist/shop/conformance.js +203 -0
  55. package/dist/shop/handler.d.ts +76 -0
  56. package/dist/shop/handler.js +156 -0
  57. package/dist/shop/index.d.ts +19 -0
  58. package/dist/shop/index.js +21 -0
  59. package/dist/shop/match.d.ts +23 -0
  60. package/dist/shop/match.js +58 -0
  61. package/dist/shop/memory.d.ts +13 -0
  62. package/dist/shop/memory.js +106 -0
  63. package/dist/shop/nodes.d.ts +47 -0
  64. package/dist/shop/nodes.js +106 -0
  65. package/dist/shop/orders.d.ts +47 -0
  66. package/dist/shop/orders.js +82 -0
  67. package/dist/shop/panel/index.d.ts +7 -0
  68. package/dist/shop/panel/index.js +11 -0
  69. package/dist/shop/panel/looks.d.ts +30 -0
  70. package/dist/shop/panel/looks.js +61 -0
  71. package/dist/shop/panel/mount.d.ts +52 -0
  72. package/dist/shop/panel/mount.js +194 -0
  73. package/dist/shop/panel/pay.d.ts +24 -0
  74. package/dist/shop/panel/pay.js +115 -0
  75. package/dist/shop/panel/qr.d.ts +9 -0
  76. package/dist/shop/panel/qr.js +281 -0
  77. package/dist/shop/price.d.ts +23 -0
  78. package/dist/shop/price.js +43 -0
  79. package/dist/shop/scan.d.ts +24 -0
  80. package/dist/shop/scan.js +80 -0
  81. package/dist/shop/security.d.ts +10 -0
  82. package/dist/shop/security.js +56 -0
  83. package/dist/shop/store.d.ts +101 -0
  84. package/dist/shop/store.js +4 -0
  85. package/dist/shop/worker.d.ts +43 -0
  86. package/dist/shop/worker.js +76 -0
  87. package/package.json +21 -6
  88. package/src/admin/panel/dom.ts +6 -0
  89. package/src/admin/panel/mount.ts +7 -2
  90. package/src/emotes/panel/mount.ts +9 -2
  91. package/src/friends/panel/mount.ts +8 -2
  92. package/src/options/panel/mount.ts +8 -3
  93. package/src/player-signin/adapters/d1.sql +45 -0
  94. package/src/player-signin/adapters/d1.ts +120 -0
  95. package/src/player-signin/adapters/firestore.ts +194 -0
  96. package/src/player-signin/conformance.ts +184 -0
  97. package/src/player-signin/handler.ts +404 -0
  98. package/src/player-signin/host.ts +12 -0
  99. package/src/player-signin/index.ts +20 -0
  100. package/src/player-signin/memory.ts +94 -0
  101. package/src/player-signin/panel/index.ts +12 -0
  102. package/src/player-signin/panel/memo.ts +49 -0
  103. package/src/player-signin/panel/mount.ts +127 -0
  104. package/src/player-signin/panel/style.css +473 -0
  105. package/src/player-signin/panel/view.ts +428 -0
  106. package/src/player-signin/panel/words.ts +32 -0
  107. package/src/player-signin/scheme-check.ts +14 -0
  108. package/src/player-signin/scheme.ts +32 -0
  109. package/src/player-signin/types.ts +103 -0
  110. package/src/shop/adapters/d1.sql +52 -0
  111. package/src/shop/adapters/d1.ts +245 -0
  112. package/src/shop/adapters/firestore.ts +281 -0
  113. package/src/shop/address.ts +102 -0
  114. package/src/shop/amount.ts +66 -0
  115. package/src/shop/conformance.ts +232 -0
  116. package/src/shop/handler.ts +202 -0
  117. package/src/shop/index.ts +30 -0
  118. package/src/shop/match.ts +63 -0
  119. package/src/shop/memory.ts +102 -0
  120. package/src/shop/nodes.ts +115 -0
  121. package/src/shop/orders.ts +93 -0
  122. package/src/shop/panel/index.ts +15 -0
  123. package/src/shop/panel/looks.ts +106 -0
  124. package/src/shop/panel/mount.ts +241 -0
  125. package/src/shop/panel/pay.ts +171 -0
  126. package/src/shop/panel/qr.ts +256 -0
  127. package/src/shop/panel/style.css +534 -0
  128. package/src/shop/price.ts +52 -0
  129. package/src/shop/scan.ts +90 -0
  130. package/src/shop/security.ts +50 -0
  131. package/src/shop/store.ts +99 -0
  132. package/src/shop/worker.ts +107 -0
@@ -0,0 +1,404 @@
1
+ // The routes of the PLAYER sign-in, on the Web `Request` and `Response` types (a Worker, Firebase Functions, Node). The same paths and answers as the
2
+ // game starter's Worker (its src/worker/signin.ts), so the starter's page keeps working, plus the passkey route:
3
+ //
4
+ // POST {base}/start the browser starts a wallet request: { way, turnstile? } → { way, id, link, walletLink?, code?, expiresAt } and the starter cookie
5
+ // GET {base}/nano/:id Berry Wallet reads the request (no cookie, no Origin)
6
+ // POST {base}/nano/:id Berry Wallet posts its signature: 204 with no body when accepted, 401 and the words when refused
7
+ // POST {base}/nautilus?id= Nautilus posts its signature: always 200 (Nautilus shows only a 200's words)
8
+ // POST {base}/nanchat NanChat's relay server posts the signature: always 200 (NanChat shows the words; a refusal starts "NOT signed in")
9
+ // POST {base}/check the browser asks every 2 seconds: { id, way } → { status: "waiting" | "expired" | "elsewhere" | "used" | "signedin", address? }
10
+ // POST {base}/passkey the passkey purse's signature: { address, at, signature, turnstile? } → { status: "signedin", address } and the session cookie
11
+ // POST {signOut} ends this browser's session: { ok: true }
12
+ //
13
+ // `base` is "/api/signin" and `signOut` "/api/signout" (the starter's). The session cookie is the starter's: `session` (`__Host-session` on https),
14
+ // HttpOnly, SameSite=Lax, 30 days; the database keeps only the SHA-256 of its token. Only the browser that started a wallet request is signed in by it
15
+ // (its starter cookie), once. The passkey's signature names the game's host and counts once within 5 minutes. The server never sees a passkey, a seed
16
+ // or a key: it checks signatures (@nano133/signin) and keeps sessions.
17
+ //
18
+ // A game whose /admin signs in on the same paths (the starter: `scope: "admin"` in the body) passes `sharedPaths: true`: a call for the admin, and a
19
+ // wallet's call for a request this store does not hold, then answer null, and the game's own admin code answers them.
20
+
21
+ import {
22
+ answerNanchat,
23
+ answerNanoSignin,
24
+ answerNautilus,
25
+ checkNanchat,
26
+ checkNanoSignin,
27
+ checkNautilus,
28
+ checkSignatureSignin,
29
+ nanchatNonce,
30
+ nanchatReply,
31
+ NANO_SIGNIN_ACCEPTED,
32
+ nanoSigninRefusal,
33
+ nautilusReply,
34
+ readNanoSignin,
35
+ SigninError,
36
+ startNanchat,
37
+ startNanoSignin,
38
+ startNautilus,
39
+ validAddress,
40
+ WalletRefused,
41
+ type RequestCheck,
42
+ } from "@nano133/signin";
43
+ import { fail, json, memoryLimiter, readLimited, sameOrigin } from "../admin/http.js";
44
+ import { PLAYER_SIGNIN_LIMITS, SESSION_DAYS, STARTER_MAX_AGE_S, TOKEN_PATTERN, viaOf, WAYS, type PlayerSession, type PlayerSigninStore, type Via, type Way } from "./types.js";
45
+
46
+ /** The address that belongs to nobody (its key is all zeros): Nautilus names it while the game has no address of its own. */
47
+ export const EXAMPLE_ADDRESS = "nano_1111111111111111111111111111111111111111111111111111hifc8npp";
48
+
49
+ export type PlayerSigninOptions = {
50
+ store: PlayerSigninStore;
51
+ /**
52
+ * A Nano address of the game's own (the starter's RECEIVE_ADDRESS). Nautilus only NAMES it in its request and in the text it signs: no key, no
53
+ * payment. Null or none: the example address.
54
+ */
55
+ siteAddress?: string | null;
56
+ /** Where the sign-in routes are. Default "/api/signin". */
57
+ base?: string;
58
+ /** The sign-out route. Default "/api/signout". */
59
+ signOutPath?: string;
60
+ /** The wallet apps the game offers, in the sheet's order. Default all three: ["berry", "nanchat", "nautilus"]. An empty list offers none. */
61
+ ways?: readonly Way[];
62
+ /** The passkey route. Default true. */
63
+ passkey?: boolean;
64
+ /**
65
+ * The game's host exactly as players see it ("mygame.com", with a port when it has one). Every signed text names it. Default: the host of each
66
+ * request. When it is set, a call to any other host is refused (403).
67
+ */
68
+ domain?: string;
69
+ /** True when the game's admin signs in on the same paths (the starter's `scope: "admin"`): its calls answer null. Default false. */
70
+ sharedPaths?: boolean;
71
+ /** The cookies' names. Default { session: "session", starter: "starter" } (the starter's). On https they get the __Host- prefix. */
72
+ cookies?: { session?: string; starter?: string };
73
+ /** How long a session lasts, in days. Default 30. */
74
+ sessionDays?: number;
75
+ /** The shared limiter (sign-in starts, passkey sign-ins, NanChat's totals): false means "too many". Default: a fixed window in this copy's memory. */
76
+ limit?: (key: string, limit: number, windowMs: number, now: number) => boolean | Promise<boolean>;
77
+ /** The visitor's key for the limits: a keyed hash of the IP address, never the address. Default: the CF-Connecting-IP header as it is. */
78
+ visitor?: (request: Request) => Promise<string>;
79
+ /** The person check (Turnstile) of a start and a passkey sign-in: true when `token` passes. No hook: no check. */
80
+ personCheck?: (token: unknown, request: Request) => Promise<boolean>;
81
+ /** The time now in ms. Default Date.now. */
82
+ now?: () => number;
83
+ /** Told about a fault (a store). The player gets a plain 500 and never sees it. */
84
+ onError?: (e: unknown) => void;
85
+ };
86
+
87
+ export type PlayerSignin = {
88
+ base: string;
89
+ /** Answers a sign-in route, or null when the request is not one (or is the admin's, with `sharedPaths`). */
90
+ handle(request: Request): Promise<Response | null>;
91
+ /** The signed-in player of this request (their Nano address), or null. The shop's and the friends' `player` hook. Never the admin's login. */
92
+ player(request: Request): Promise<string | null>;
93
+ /** The session of this request: the address, when it was made and how. */
94
+ session(request: Request): Promise<PlayerSession | null>;
95
+ /** Makes a new session for `address` (the game's own code exchange: via "nanoterra") and returns the Set-Cookie value to send. */
96
+ signIn(request: Request, address: string, via: Via): Promise<string>;
97
+ /** Ends this request's session and returns the Set-Cookie value that clears the cookie. */
98
+ signOut(request: Request): Promise<string>;
99
+ /** "Delete my data": every session and sign-in row of the player. Returns how many rows went. */
100
+ forget(player: string): Promise<number>;
101
+ /** The clean-up (a cron): old requests, ended sessions, used once-only keys. */
102
+ clean(now?: number): Promise<void>;
103
+ };
104
+
105
+ const hex = (b: ArrayBuffer | Uint8Array) => Array.from(b instanceof Uint8Array ? b : new Uint8Array(b), (x) => x.toString(16).padStart(2, "0")).join("");
106
+ export async function sha256Hex(text: string): Promise<string> {
107
+ return hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
108
+ }
109
+ const randomHex = (bytes: number) => hex(crypto.getRandomValues(new Uint8Array(bytes)));
110
+
111
+ /** Only a space or a tab around a cookie's name or value is dropped (JS trim() also drops Unicode spaces, which would let another name pass for ours). */
112
+ const trimAscii = (s: string) => s.replace(/^[ \t]+|[ \t]+$/g, "");
113
+
114
+ function cookieValue(req: Request, name: string): string | null {
115
+ for (const part of (req.headers.get("Cookie") ?? "").split(";")) {
116
+ const i = part.indexOf("=");
117
+ if (i > 0 && trimAscii(part.slice(0, i)) === name) {
118
+ try {
119
+ return decodeURIComponent(trimAscii(part.slice(i + 1)));
120
+ } catch {
121
+ // Not valid percent-coding: not ours.
122
+ }
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ const isHttps = (req: Request) => new URL(req.url).protocol === "https:";
129
+ /** A cookie for this site only: on https it gets the __Host- prefix and Secure (the starter's exact form). */
130
+ export function cookieHeader(req: Request, name: string, value: string, maxAgeSec: number): string {
131
+ const https = isHttps(req);
132
+ return `${https ? "__Host-" : ""}${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAgeSec}; HttpOnly; SameSite=Lax${https ? "; Secure" : ""}`;
133
+ }
134
+ const readCookie = (req: Request, name: string) => cookieValue(req, isHttps(req) ? `__Host-${name}` : name);
135
+
136
+ /** What the player reads for a refused passkey sign-in. */
137
+ function passkeyWords(e: SigninError): string {
138
+ if (e.status === 409) return "That sign-in was used already. Try again.";
139
+ if (e.status === 401) return "The signature does not match. Try again.";
140
+ if (/too old/.test(e.message)) return "That sign-in is too old. Check the clock of this device, then try again.";
141
+ return "That sign-in could not be read. Try again.";
142
+ }
143
+
144
+ const fallbackLimit = memoryLimiter();
145
+
146
+ export function createPlayerSignin(options: PlayerSigninOptions): PlayerSignin {
147
+ const base = options.base ?? "/api/signin";
148
+ const signOutPath = options.signOutPath ?? "/api/signout";
149
+ for (const p of [base, signOutPath]) if (!p.startsWith("/") || p.length < 2 || p.endsWith("/")) throw new Error('a path must start with "/" and not end with "/", like "/api/signin"');
150
+ const ways: readonly Way[] = options.ways ?? WAYS;
151
+ for (const w of ways) if (!WAYS.includes(w)) throw new Error(`no such wallet app: ${String(w)} (the ways are ${WAYS.join(", ")})`);
152
+ const passkeyOn = options.passkey !== false;
153
+ const store = options.store;
154
+ const sessionName = options.cookies?.session ?? "session";
155
+ const starterName = options.cookies?.starter ?? "starter";
156
+ for (const n of [sessionName, starterName]) if (!/^[A-Za-z0-9_-]{1,40}$/.test(n)) throw new Error("a cookie's name is letters, digits, _ and - only");
157
+ const sessionMs = Math.max(1, options.sessionDays ?? SESSION_DAYS) * 86_400_000;
158
+ const now = options.now ?? (() => Date.now());
159
+ const limit = options.limit ?? fallbackLimit;
160
+ const memory = memoryLimiter();
161
+ const siteAddress = options.siteAddress && validAddress(options.siteAddress) ? options.siteAddress : EXAMPLE_ADDRESS;
162
+ const report = (e: unknown) => {
163
+ try {
164
+ options.onError?.(e);
165
+ } catch {
166
+ // nothing more can be done
167
+ }
168
+ };
169
+ const ipOf = (req: Request) => req.headers.get("CF-Connecting-IP") ?? "anyone";
170
+ const visitorOf = async (req: Request) => (options.visitor ? await options.visitor(req) : ipOf(req));
171
+ const person = async (token: unknown, req: Request) => (options.personCheck ? options.personCheck(token, req) : true);
172
+
173
+ /** Each wallet's options for this request's site: the domain and page come from the address the player opened (or `domain`). */
174
+ function walletsFor(req: Request, at: number) {
175
+ const url = new URL(req.url);
176
+ const host = options.domain ?? url.host;
177
+ const origin = `${url.protocol}//${host}`;
178
+ const clock = { now: () => at };
179
+ return {
180
+ host,
181
+ berry: { domain: host, uri: `${origin}/`, store: store.requests("berry"), ...clock },
182
+ nautilus: { site: host, siteAddress, store: store.requests("nautilus"), ...clock },
183
+ nanchat: { host, store: store.requests("nanchat"), ...clock },
184
+ requestUrl: (id: string) => `${origin}${base}/nano/${id}`,
185
+ nautilusUrl: (id: string) => `${origin}${base}/nautilus?id=${id}`,
186
+ nanchatUrl: `${origin}${base}/nanchat`,
187
+ };
188
+ }
189
+
190
+ async function body(req: Request): Promise<Record<string, unknown> | null> {
191
+ const text = await readLimited(req, PLAYER_SIGNIN_LIMITS.bodyMax);
192
+ if (text === null) return null;
193
+ if (!text) return {};
194
+ try {
195
+ const v: unknown = JSON.parse(text);
196
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ /** The way a call names; Berry Wallet when it names none (the starter's page sends none for Berry). Null for anything else. */
203
+ const wayOf = (v: unknown): Way | null => (v === undefined ? (ways.includes("berry") ? "berry" : null) : (ways.find((w) => w === v) ?? null));
204
+
205
+ async function newSession(address: string, via: Via, at: number) {
206
+ const token = randomHex(32);
207
+ return { token, record: { idHash: await sha256Hex(token), address, createdAt: at, expiresAt: at + sessionMs, via } };
208
+ }
209
+
210
+ async function start(req: Request, b: Record<string, unknown>, at: number): Promise<Response> {
211
+ const way = wayOf(b.way);
212
+ if (!way) return fail(400, "That wallet is not one of this game's.");
213
+ if (!(await limit(`signin|${await visitorOf(req)}`, PLAYER_SIGNIN_LIMITS.signin.limit, PLAYER_SIGNIN_LIMITS.signin.windowMs, at))) return fail(429, "Too many sign-ins. Wait a few minutes.");
214
+ if (!(await person(b.turnstile, req))) return fail(403, "The person check failed. Try again.");
215
+ const starter = randomHex(32);
216
+ const hash = await sha256Hex(starter);
217
+ const w = walletsFor(req, at);
218
+ let out: Record<string, unknown>;
219
+ if (way === "nautilus") {
220
+ const s = await startNautilus(w.nautilus, { starter: hash, answerUrl: w.nautilusUrl });
221
+ out = { way, id: s.id, link: s.link, code: s.code, expiresAt: s.expiresAt };
222
+ } else if (way === "nanchat") {
223
+ const s = await startNanchat(w.nanchat, { starter: hash, answerUrl: w.nanchatUrl });
224
+ out = { way, id: s.id, link: s.link, expiresAt: s.expiresAt };
225
+ } else {
226
+ const s = await startNanoSignin(w.berry, { starter: hash, requestUrl: w.requestUrl });
227
+ out = { way, id: s.id, link: s.link, walletLink: s.walletLink, expiresAt: s.expiresAt };
228
+ }
229
+ return json(out, 200, { "Set-Cookie": cookieHeader(req, starterName, starter, STARTER_MAX_AGE_S) });
230
+ }
231
+
232
+ async function check(req: Request, b: Record<string, unknown>, at: number): Promise<Response> {
233
+ const way = wayOf(b.way);
234
+ if (!way) return fail(400, "That wallet is not one of this game's.");
235
+ if (typeof b.id !== "string") return fail(400, "no request id");
236
+ const cookie = readCookie(req, starterName);
237
+ const starter = cookie && TOKEN_PATTERN.test(cookie) ? await sha256Hex(cookie) : undefined;
238
+ const o = { store: store.requests(way), now: () => at };
239
+ const r: RequestCheck = way === "nautilus" ? await checkNautilus(o, b.id, starter) : way === "nanchat" ? await checkNanchat(o, b.id, starter) : await checkNanoSignin(o, b.id, starter);
240
+ if (r.status !== "signedin") return json({ status: r.status });
241
+ if (!r.mine || !r.account) return json({ status: "elsewhere" });
242
+ // Once: the delivery and the session are one step of the store; a second check of the same request makes no second session.
243
+ const { token, record } = await newSession(r.account, "wallet", at);
244
+ if (!(await store.deliver(way, r.key ?? b.id, record))) return json({ status: "used" });
245
+ return json({ status: "signedin", address: r.account }, 200, { "Set-Cookie": cookieHeader(req, sessionName, token, sessionMs / 1000) });
246
+ }
247
+
248
+ async function passkey(req: Request, b: Record<string, unknown>, at: number): Promise<Response> {
249
+ if (!(await limit(`signin|${await visitorOf(req)}`, PLAYER_SIGNIN_LIMITS.signin.limit, PLAYER_SIGNIN_LIMITS.signin.windowMs, at))) return fail(429, "Too many sign-ins. Wait a few minutes.");
250
+ if (!(await person(b.turnstile, req))) return fail(403, "The person check failed. Try again.");
251
+ let address: string;
252
+ try {
253
+ address = await checkSignatureSignin({ domain: walletsFor(req, at).host, useOnce: (k, ttl, a) => store.useOnce(k, ttl, a), now: () => at }, { address: b.address as string, at: Number(b.at), signature: b.signature as string });
254
+ } catch (e) {
255
+ if (e instanceof SigninError) return fail(e.status, passkeyWords(e));
256
+ throw e;
257
+ }
258
+ const { token, record } = await newSession(address, "passkey", at);
259
+ await store.createSession(record);
260
+ return json({ status: "signedin", address }, 200, { "Set-Cookie": cookieHeader(req, sessionName, token, sessionMs / 1000) });
261
+ }
262
+
263
+ /** Berry Wallet's GET and POST. They come from the wallet app: no cookie, no Origin. */
264
+ async function berry(req: Request, id: string, at: number): Promise<Response | null> {
265
+ if (options.sharedPaths && !(await store.requests("berry").get(id))) return null;
266
+ const w = walletsFor(req, at);
267
+ if (req.method === "GET") {
268
+ const request = await readNanoSignin(w.berry, id, w.requestUrl);
269
+ return request ? json(request) : json(nanoSigninRefusal("This sign-in expired."), 404);
270
+ }
271
+ const b = await body(req);
272
+ if (!b) return json(nanoSigninRefusal("This answer is not readable."), 401);
273
+ try {
274
+ await answerNanoSignin(w.berry, id, b, w.requestUrl);
275
+ // Berry Wallet shows its success screen only for a 204 with no body.
276
+ return new Response(null, { status: NANO_SIGNIN_ACCEPTED, headers: { "Cache-Control": "no-store" } });
277
+ } catch (e) {
278
+ if (e instanceof WalletRefused) return json(nanoSigninRefusal(e.message), 401);
279
+ throw e;
280
+ }
281
+ }
282
+
283
+ async function nautilus(req: Request, at: number): Promise<Response | null> {
284
+ const id = new URL(req.url).searchParams.get("id") ?? "";
285
+ if (options.sharedPaths && !(await store.requests("nautilus").get(id))) return null;
286
+ const w = walletsFor(req, at);
287
+ const b = await body(req);
288
+ if (!b) return json(nautilusReply(w.host, "That answer could not be read."));
289
+ try {
290
+ await answerNautilus(w.nautilus, id, b);
291
+ return json(nautilusReply(w.host));
292
+ } catch (e) {
293
+ if (e instanceof WalletRefused) return json(nautilusReply(w.host, e.message));
294
+ throw e;
295
+ }
296
+ }
297
+
298
+ /** NanChat's answer: its relay server posts every one, so the limits are totals and per nonce, never per visitor. */
299
+ async function nanchat(req: Request, at: number): Promise<Response> {
300
+ const w = walletsFor(req, at);
301
+ const refuse = (words: string) => json(nanchatReply(w.host, words));
302
+ if (!memory("nanchat", PLAYER_SIGNIN_LIMITS.nanchatRoute.limit, PLAYER_SIGNIN_LIMITS.nanchatRoute.windowMs, at)) return refuse("too many sign-ins at once. Wait a minute.");
303
+ const b = await body(req);
304
+ if (!b) return refuse("that answer could not be read.");
305
+ const nonce = nanchatNonce(w.host, b.message);
306
+ if (nonce && typeof b.account === "string" && typeof b.signature === "string" && /^[0-9A-Fa-f]{128}$/.test(b.signature)) {
307
+ if (!(await limit("nanchat|all", PLAYER_SIGNIN_LIMITS.nanchatAll.limit, PLAYER_SIGNIN_LIMITS.nanchatAll.windowMs, at))) return refuse("too many sign-ins at once. Wait a few minutes.");
308
+ if (!(await limit(`nanchat|${nonce}`, PLAYER_SIGNIN_LIMITS.nanchatNonce.limit, PLAYER_SIGNIN_LIMITS.nanchatNonce.windowMs, at))) return refuse(`too many tries for this sign-in. Start again on ${w.host}.`);
309
+ }
310
+ try {
311
+ await answerNanchat(w.nanchat, b);
312
+ return json(nanchatReply(w.host));
313
+ } catch (e) {
314
+ if (e instanceof WalletRefused) return refuse(e.message);
315
+ throw e;
316
+ }
317
+ }
318
+
319
+ async function sessionOf(req: Request): Promise<PlayerSession | null> {
320
+ const token = readCookie(req, sessionName);
321
+ if (!token || !TOKEN_PATTERN.test(token)) return null;
322
+ const s = await store.session(await sha256Hex(token), now());
323
+ return s ? { address: s.address, createdAt: s.createdAt, via: viaOf(s.via) } : null;
324
+ }
325
+
326
+ async function signOut(req: Request): Promise<string> {
327
+ const token = readCookie(req, sessionName);
328
+ if (token && TOKEN_PATTERN.test(token)) await store.endSession(await sha256Hex(token));
329
+ return cookieHeader(req, sessionName, "", 0);
330
+ }
331
+
332
+ async function handle(req: Request): Promise<Response | null> {
333
+ const url = new URL(req.url);
334
+ const path = url.pathname;
335
+ const berryPath = path.startsWith(`${base}/nano/`) ? /^[0-9a-f]{32}$/.exec(path.slice(base.length + 6))?.[0] : undefined;
336
+ const browserRoutes = [`${base}/start`, `${base}/check`, `${base}/passkey`, signOutPath];
337
+ const mine = browserRoutes.includes(path) || berryPath !== undefined || path === `${base}/nautilus` || path === `${base}/nanchat`;
338
+ if (!mine) return null;
339
+ if (path === `${base}/passkey` && !passkeyOn) return null;
340
+ try {
341
+ if (options.domain && url.host !== options.domain) return fail(403, `This sign-in belongs to ${options.domain}.`);
342
+ const at = now();
343
+ const ip = ipOf(req);
344
+
345
+ // The wallet apps' routes: no cookie and no Origin (an app, or NanChat's relay server, posts them: not a browser page).
346
+ if (berryPath !== undefined) {
347
+ if (req.method !== "GET" && req.method !== "POST") return fail(405, "Not allowed.", {}, { Allow: "GET, POST" });
348
+ if (!ways.includes("berry")) return null;
349
+ if (!memory(`wallet|${ip}`, PLAYER_SIGNIN_LIMITS.wallet.limit, PLAYER_SIGNIN_LIMITS.wallet.windowMs, at)) return fail(429, "Too many requests.");
350
+ return await berry(req, berryPath, at);
351
+ }
352
+ if (path === `${base}/nautilus`) {
353
+ if (req.method !== "POST" || !ways.includes("nautilus")) return null;
354
+ // Nautilus shows the words of a refusal only with HTTP 200.
355
+ if (!memory(`wallet|${ip}`, PLAYER_SIGNIN_LIMITS.wallet.limit, PLAYER_SIGNIN_LIMITS.wallet.windowMs, at)) return json(nautilusReply(walletsFor(req, at).host, "Too many requests. Wait a minute."));
356
+ return await nautilus(req, at);
357
+ }
358
+ if (path === `${base}/nanchat`) {
359
+ if (req.method !== "POST" || !ways.includes("nanchat")) return null;
360
+ return await nanchat(req, at);
361
+ }
362
+
363
+ // The browser's own calls: a POST from the game's own page.
364
+ if (req.method !== "POST") return fail(405, "Not allowed.", {}, { Allow: "POST" });
365
+ // Read on a copy: a call that is the admin's goes back to the game whole.
366
+ const b = await body(options.sharedPaths ? req.clone() : req);
367
+ if (b && b.scope === "admin") return options.sharedPaths ? null : fail(400, "This game has no such sign-in.");
368
+ if (!sameOrigin(req)) return fail(403, "This call must come from the game's own page.");
369
+ if (path === signOutPath) {
370
+ // A body that is not JSON (an older page) signs out too.
371
+ return json({ ok: true }, 200, { "Set-Cookie": await signOut(req) });
372
+ }
373
+ if (!b) return fail(400, "That request is not readable.");
374
+ if (path === `${base}/check`) {
375
+ if (!memory(`poll|${ip}`, PLAYER_SIGNIN_LIMITS.poll.limit, PLAYER_SIGNIN_LIMITS.poll.windowMs, at)) return fail(429, "Too many requests. Wait a minute.");
376
+ return await check(req, b, at);
377
+ }
378
+ if (path === `${base}/start`) return await start(req, b, at);
379
+ return await passkey(req, b, at);
380
+ } catch (e) {
381
+ report(e);
382
+ return fail(500, "Something went wrong. Try again.");
383
+ }
384
+ }
385
+
386
+ return {
387
+ base,
388
+ handle,
389
+ async player(req) {
390
+ return (await sessionOf(req))?.address ?? null;
391
+ },
392
+ session: sessionOf,
393
+ async signIn(req, address, via) {
394
+ if (!validAddress(address)) throw new Error("a session is for a whole Nano address");
395
+ if (!(["wallet", "passkey", "nanoterra"] as const).includes(via)) throw new Error("say how the player signed in: wallet, passkey or nanoterra");
396
+ const { token, record } = await newSession(address, via, now());
397
+ await store.createSession(record);
398
+ return cookieHeader(req, sessionName, token, sessionMs / 1000);
399
+ },
400
+ signOut,
401
+ forget: (player) => store.forget(player),
402
+ clean: (at = now()) => store.clean(at),
403
+ };
404
+ }
@@ -0,0 +1,12 @@
1
+ // Where a passkey may be made. A passkey belongs to one domain for good: one made on a preview address (`*.workers.dev`, where the game starter
2
+ // runs first) is lost when the game moves to its own domain, and the player's looks go with it. So the sheet offers no passkey there by default,
3
+ // and the admin says why. No DOM and no package: the admin panel uses it too.
4
+
5
+ /** True for a preview address that a game leaves for its own domain: `*.workers.dev`. */
6
+ export function previewHost(hostname: string): boolean {
7
+ const host = hostname.toLowerCase().replace(/\.$/, "");
8
+ return host === "workers.dev" || host.endsWith(".workers.dev");
9
+ }
10
+
11
+ /** What the admin says on a preview address of a game that offers its players a passkey. */
12
+ export const PASSKEY_PREVIEW_NOTE = "Passkeys turn on when your game has its own domain.";
@@ -0,0 +1,20 @@
1
+ // "@nano133/nanoterra/player-signin": the PLAYER sign-in of a game, in one path: a passkey (the passkey purse: a Nano account made in the browser
2
+ // from the player's passkey) and the three wallet apps (Berry Wallet, NanChat, Nautilus). No password, no email, no payment, and the server holds no
3
+ // key: every way is a signature, checked by @nano133/signin (an optional peer: install it with this package).
4
+ //
5
+ // createPlayerSignin the routes (start, check, the wallets' answers, the passkey, sign-out), sessions, `player(request)` for the shop
6
+ // and the friends list, `forget(player)` for "Delete my data"
7
+ // createMemoryPlayerSignin a store in memory, for tests
8
+ // checkScheme, schemeVector the game's frozen passkey scheme, and its fixed vectors
9
+ // (the adapters are "/player-signin/d1" (with its SQL, "/player-signin/d1.sql") and "/player-signin/firestore", the conformance suite
10
+ // "/player-signin/conformance", the browser sheet "/player-signin/panel" and its "/player-signin/style.css")
11
+
12
+ export { EXAMPLE_ADDRESS, cookieHeader, createPlayerSignin, sha256Hex } from "./handler.js";
13
+ export type { PlayerSignin, PlayerSigninOptions } from "./handler.js";
14
+ export { createMemoryPlayerSignin } from "./memory.js";
15
+ export type { MemoryPlayerSignin } from "./memory.js";
16
+ export { checkScheme, schemeVector } from "./scheme.js";
17
+ export { PASSKEY_PREVIEW_NOTE, previewHost } from "./host.js";
18
+ export type { PasskeyScheme } from "./scheme.js";
19
+ export { PLAYER_SIGNIN_LIMITS, SESSION_DAYS, STARTER_MAX_AGE_S, VIAS, WAYS, viaOf } from "./types.js";
20
+ export type { PlayerSession, PlayerSigninStore, RequestStoreLike, SessionRecord, SigninRequest, Via, Way } from "./types.js";
@@ -0,0 +1,94 @@
1
+ // The player sign-in's store in this process's memory: for tests and demos. A game uses a database (./adapters/d1.ts, ./adapters/firestore.ts, or
2
+ // its own store that passes playerSigninStoreConformance).
3
+
4
+ import { WAYS, type PlayerSigninStore, type RequestStoreLike, type SessionRecord, type SigninRequest, type Way } from "./types.js";
5
+
6
+ type RequestRow = SigninRequest & { way: Way; delivered: boolean };
7
+
8
+ export type MemoryPlayerSignin = PlayerSigninStore & {
9
+ readonly requestRows: Map<string, RequestRow>;
10
+ readonly sessionRows: Map<string, SessionRecord>;
11
+ readonly onceRows: Map<string, { address: string; until: number }>;
12
+ };
13
+
14
+ const copy = <T>(v: T): T => structuredClone(v);
15
+
16
+ export function createMemoryPlayerSignin(): MemoryPlayerSignin {
17
+ const requestRows = new Map<string, RequestRow>();
18
+ const sessionRows = new Map<string, SessionRecord>();
19
+ const onceRows = new Map<string, { address: string; until: number }>();
20
+ const strip = ({ way: _way, delivered: _delivered, ...r }: RequestRow): SigninRequest => copy(r);
21
+
22
+ const stores = new Map<Way, RequestStoreLike>();
23
+ for (const way of WAYS) {
24
+ stores.set(way, {
25
+ async create(key, r) {
26
+ if (requestRows.has(key)) throw new Error("a request with this key exists");
27
+ requestRows.set(key, { ...copy(r), way, delivered: false });
28
+ },
29
+ async get(key) {
30
+ const r = requestRows.get(key);
31
+ return r && r.way === way ? strip(r) : null;
32
+ },
33
+ async find(id) {
34
+ for (const [key, r] of requestRows) if (r.way === way && r.id === id) return { key, request: strip(r) };
35
+ return null;
36
+ },
37
+ async finish(key, account) {
38
+ const r = requestRows.get(key);
39
+ if (!r || r.way !== way || r.status !== "waiting") return false;
40
+ r.status = "signedin";
41
+ r.account = account;
42
+ return true;
43
+ },
44
+ });
45
+ }
46
+
47
+ return {
48
+ requestRows,
49
+ sessionRows,
50
+ onceRows,
51
+ requests: (way) => {
52
+ const s = stores.get(way);
53
+ if (!s) throw new Error(`no such way: ${String(way)}`);
54
+ return s;
55
+ },
56
+ async deliver(way, key, session) {
57
+ const r = requestRows.get(key);
58
+ if (!r || r.way !== way || r.status !== "signedin" || r.delivered) return false;
59
+ r.delivered = true;
60
+ sessionRows.set(session.idHash, copy(session));
61
+ return true;
62
+ },
63
+ async createSession(session) {
64
+ if (sessionRows.has(session.idHash)) throw new Error("a session with this id exists");
65
+ sessionRows.set(session.idHash, copy(session));
66
+ },
67
+ async session(idHash, now) {
68
+ const s = sessionRows.get(idHash);
69
+ return s && s.expiresAt > now ? copy(s) : null;
70
+ },
71
+ async endSession(idHash) {
72
+ sessionRows.delete(idHash);
73
+ },
74
+ async useOnce(key, ttlMs, address) {
75
+ const now = Date.now();
76
+ const had = onceRows.get(key);
77
+ if (had && had.until > now) return false;
78
+ onceRows.set(key, { address, until: now + ttlMs });
79
+ return true;
80
+ },
81
+ async forget(address) {
82
+ let n = 0;
83
+ for (const [k, s] of sessionRows) if (s.address === address && sessionRows.delete(k)) n++;
84
+ for (const [k, r] of requestRows) if (r.account === address && requestRows.delete(k)) n++;
85
+ for (const [k, o] of onceRows) if (o.address === address && onceRows.delete(k)) n++;
86
+ return n;
87
+ },
88
+ async clean(now) {
89
+ for (const [k, r] of requestRows) if (r.expiresAt < now - 2 * 3_600_000) requestRows.delete(k);
90
+ for (const [k, s] of sessionRows) if (s.expiresAt <= now) sessionRows.delete(k);
91
+ for (const [k, o] of onceRows) if (o.until <= now) onceRows.delete(k);
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,12 @@
1
+ // "@nano133/nanoterra/player-signin/panel": the player's sign-in sheet in the browser. `mountPlayerSignin` is the sheet (call `open()`);
2
+ // `signInView` is its content, for a game that has a sheet of its own. Add "@nano133/nanoterra/player-signin/style.css" to the page.
3
+
4
+ export { mountPlayerSignin } from "./mount.js";
5
+ export type { MountedPlayerSignin, PlayerSigninMountOptions } from "./mount.js";
6
+ export { hostFits, passkeyName, signInView } from "./view.js";
7
+ export { PASSKEY_PREVIEW_NOTE, previewHost } from "../host.js";
8
+ export type { PlayerSigninView, PlayerSigninViewOptions, SignedIn } from "./view.js";
9
+ export { faultWords } from "./words.js";
10
+ export type { FaultWords } from "./words.js";
11
+ export { readMemo } from "./memo.js";
12
+ export type { SigninMemo } from "./memo.js";
@@ -0,0 +1,49 @@
1
+ // What this browser remembers of the player's sign-in, in localStorage (no secret, no address): the ids of the passkeys it made or used (so the device
2
+ // refuses a second passkey here, and "Use my passkey" is the main key on the next visit), the ids made here and never opened (their first open is a
3
+ // NEW account: the spare key shows then), and the way chosen last. A browser that refuses storage remembers nothing, and nothing depends on it.
4
+
5
+ import type { Way } from "../types.js";
6
+
7
+ export type SigninMemo = { ids: string[]; unopened: string[]; last: Way | "passkey" | null };
8
+
9
+ const KEY = (rpId: string) => `n133.player-signin.v1:${rpId}`;
10
+ const isId = (x: unknown): x is string => typeof x === "string" && /^[A-Za-z0-9_-]{8,700}$/.test(x);
11
+ const LAST = new Set(["passkey", "berry", "nanchat", "nautilus"]);
12
+
13
+ export function readMemo(rpId: string): SigninMemo {
14
+ try {
15
+ const m = JSON.parse(globalThis.localStorage?.getItem(KEY(rpId)) ?? "null") as Partial<SigninMemo> | null;
16
+ const ids = Array.isArray(m?.ids) ? m.ids.filter(isId).slice(-8) : [];
17
+ const unopened = Array.isArray(m?.unopened) ? m.unopened.filter((x) => isId(x) && ids.includes(x)) : [];
18
+ const last = typeof m?.last === "string" && LAST.has(m.last) ? (m.last as SigninMemo["last"]) : null;
19
+ return { ids, unopened, last };
20
+ } catch {
21
+ return { ids: [], unopened: [], last: null };
22
+ }
23
+ }
24
+
25
+ function write(rpId: string, m: SigninMemo): void {
26
+ try {
27
+ globalThis.localStorage?.setItem(KEY(rpId), JSON.stringify(m));
28
+ } catch {
29
+ // Storage refused (a private window): nothing is remembered.
30
+ }
31
+ }
32
+
33
+ /** A passkey was just MADE and not used yet: remember it at once (a closed sheet or a reload between the two steps must not forget it). */
34
+ export function rememberMade(rpId: string, id: string): void {
35
+ const m = readMemo(rpId);
36
+ write(rpId, { ...m, ids: [...m.ids.filter((x) => x !== id), id].slice(-8), unopened: [...m.unopened.filter((x) => x !== id), id] });
37
+ }
38
+
39
+ /** A passkey signed the player in. Returns true when it was made here and never opened before: a new account. */
40
+ export function rememberOpened(rpId: string, id: string): boolean {
41
+ const m = readMemo(rpId);
42
+ const fresh = m.unopened.includes(id);
43
+ write(rpId, { ids: [...m.ids.filter((x) => x !== id), id].slice(-8), unopened: m.unopened.filter((x) => x !== id), last: "passkey" });
44
+ return fresh;
45
+ }
46
+
47
+ export function rememberLast(rpId: string, last: Way | "passkey"): void {
48
+ write(rpId, { ...readMemo(rpId), last });
49
+ }