@nano133/nanoterra 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +342 -6
  3. package/ai/SPEC.md +65 -3
  4. package/dist/admin/panel/mount.d.ts +3 -0
  5. package/dist/admin/panel/mount.js +4 -2
  6. package/dist/player-signin/adapters/d1.d.ts +10 -0
  7. package/dist/player-signin/adapters/d1.js +108 -0
  8. package/dist/player-signin/adapters/firestore.d.ts +45 -0
  9. package/dist/player-signin/adapters/firestore.js +168 -0
  10. package/dist/player-signin/conformance.d.ts +11 -0
  11. package/dist/player-signin/conformance.js +160 -0
  12. package/dist/player-signin/handler.d.ts +64 -0
  13. package/dist/player-signin/handler.js +364 -0
  14. package/dist/player-signin/host.d.ts +4 -0
  15. package/dist/player-signin/host.js +10 -0
  16. package/dist/player-signin/index.d.ts +9 -0
  17. package/dist/player-signin/index.js +15 -0
  18. package/dist/player-signin/memory.d.ts +15 -0
  19. package/dist/player-signin/memory.js +101 -0
  20. package/dist/player-signin/panel/index.d.ts +9 -0
  21. package/dist/player-signin/panel/index.js +7 -0
  22. package/dist/player-signin/panel/memo.d.ts +12 -0
  23. package/dist/player-signin/panel/memo.js +41 -0
  24. package/dist/player-signin/panel/mount.d.ts +21 -0
  25. package/dist/player-signin/panel/mount.js +105 -0
  26. package/dist/player-signin/panel/view.d.ts +45 -0
  27. package/dist/player-signin/panel/view.js +360 -0
  28. package/dist/player-signin/panel/words.d.ts +8 -0
  29. package/dist/player-signin/panel/words.js +26 -0
  30. package/dist/player-signin/scheme-check.d.ts +3 -0
  31. package/dist/player-signin/scheme-check.js +14 -0
  32. package/dist/player-signin/scheme.d.ts +13 -0
  33. package/dist/player-signin/scheme.js +28 -0
  34. package/dist/player-signin/types.d.ts +116 -0
  35. package/dist/player-signin/types.js +33 -0
  36. package/dist/shop/adapters/d1.d.ts +25 -0
  37. package/dist/shop/adapters/d1.js +200 -0
  38. package/dist/shop/adapters/firestore.d.ts +53 -0
  39. package/dist/shop/adapters/firestore.js +245 -0
  40. package/dist/shop/address.d.ts +14 -0
  41. package/dist/shop/address.js +104 -0
  42. package/dist/shop/amount.d.ts +26 -0
  43. package/dist/shop/amount.js +59 -0
  44. package/dist/shop/conformance.d.ts +13 -0
  45. package/dist/shop/conformance.js +203 -0
  46. package/dist/shop/handler.d.ts +76 -0
  47. package/dist/shop/handler.js +156 -0
  48. package/dist/shop/index.d.ts +19 -0
  49. package/dist/shop/index.js +21 -0
  50. package/dist/shop/match.d.ts +23 -0
  51. package/dist/shop/match.js +58 -0
  52. package/dist/shop/memory.d.ts +13 -0
  53. package/dist/shop/memory.js +106 -0
  54. package/dist/shop/nodes.d.ts +47 -0
  55. package/dist/shop/nodes.js +106 -0
  56. package/dist/shop/orders.d.ts +47 -0
  57. package/dist/shop/orders.js +82 -0
  58. package/dist/shop/panel/index.d.ts +7 -0
  59. package/dist/shop/panel/index.js +11 -0
  60. package/dist/shop/panel/looks.d.ts +30 -0
  61. package/dist/shop/panel/looks.js +61 -0
  62. package/dist/shop/panel/mount.d.ts +52 -0
  63. package/dist/shop/panel/mount.js +194 -0
  64. package/dist/shop/panel/pay.d.ts +24 -0
  65. package/dist/shop/panel/pay.js +115 -0
  66. package/dist/shop/panel/qr.d.ts +9 -0
  67. package/dist/shop/panel/qr.js +281 -0
  68. package/dist/shop/price.d.ts +23 -0
  69. package/dist/shop/price.js +43 -0
  70. package/dist/shop/scan.d.ts +24 -0
  71. package/dist/shop/scan.js +80 -0
  72. package/dist/shop/security.d.ts +10 -0
  73. package/dist/shop/security.js +56 -0
  74. package/dist/shop/store.d.ts +101 -0
  75. package/dist/shop/store.js +4 -0
  76. package/dist/shop/worker.d.ts +43 -0
  77. package/dist/shop/worker.js +76 -0
  78. package/package.json +21 -6
  79. package/src/admin/panel/mount.ts +7 -2
  80. package/src/player-signin/adapters/d1.sql +45 -0
  81. package/src/player-signin/adapters/d1.ts +120 -0
  82. package/src/player-signin/adapters/firestore.ts +194 -0
  83. package/src/player-signin/conformance.ts +184 -0
  84. package/src/player-signin/handler.ts +404 -0
  85. package/src/player-signin/host.ts +12 -0
  86. package/src/player-signin/index.ts +20 -0
  87. package/src/player-signin/memory.ts +94 -0
  88. package/src/player-signin/panel/index.ts +12 -0
  89. package/src/player-signin/panel/memo.ts +49 -0
  90. package/src/player-signin/panel/mount.ts +127 -0
  91. package/src/player-signin/panel/style.css +473 -0
  92. package/src/player-signin/panel/view.ts +428 -0
  93. package/src/player-signin/panel/words.ts +32 -0
  94. package/src/player-signin/scheme-check.ts +14 -0
  95. package/src/player-signin/scheme.ts +32 -0
  96. package/src/player-signin/types.ts +103 -0
  97. package/src/shop/adapters/d1.sql +52 -0
  98. package/src/shop/adapters/d1.ts +245 -0
  99. package/src/shop/adapters/firestore.ts +281 -0
  100. package/src/shop/address.ts +102 -0
  101. package/src/shop/amount.ts +66 -0
  102. package/src/shop/conformance.ts +232 -0
  103. package/src/shop/handler.ts +202 -0
  104. package/src/shop/index.ts +30 -0
  105. package/src/shop/match.ts +63 -0
  106. package/src/shop/memory.ts +102 -0
  107. package/src/shop/nodes.ts +115 -0
  108. package/src/shop/orders.ts +93 -0
  109. package/src/shop/panel/index.ts +15 -0
  110. package/src/shop/panel/looks.ts +106 -0
  111. package/src/shop/panel/mount.ts +241 -0
  112. package/src/shop/panel/pay.ts +171 -0
  113. package/src/shop/panel/qr.ts +256 -0
  114. package/src/shop/panel/style.css +534 -0
  115. package/src/shop/price.ts +52 -0
  116. package/src/shop/scan.ts +90 -0
  117. package/src/shop/security.ts +50 -0
  118. package/src/shop/store.ts +99 -0
  119. package/src/shop/worker.ts +107 -0
@@ -0,0 +1,364 @@
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
+ import { answerNanchat, answerNanoSignin, answerNautilus, checkNanchat, checkNanoSignin, checkNautilus, checkSignatureSignin, nanchatNonce, nanchatReply, NANO_SIGNIN_ACCEPTED, nanoSigninRefusal, nautilusReply, readNanoSignin, SigninError, startNanchat, startNanoSignin, startNautilus, validAddress, WalletRefused, } from "@nano133/signin";
21
+ import { fail, json, memoryLimiter, readLimited, sameOrigin } from "../admin/http.js";
22
+ import { PLAYER_SIGNIN_LIMITS, SESSION_DAYS, STARTER_MAX_AGE_S, TOKEN_PATTERN, viaOf, WAYS } from "./types.js";
23
+ /** The address that belongs to nobody (its key is all zeros): Nautilus names it while the game has no address of its own. */
24
+ export const EXAMPLE_ADDRESS = "nano_1111111111111111111111111111111111111111111111111111hifc8npp";
25
+ const hex = (b) => Array.from(b instanceof Uint8Array ? b : new Uint8Array(b), (x) => x.toString(16).padStart(2, "0")).join("");
26
+ export async function sha256Hex(text) {
27
+ return hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
28
+ }
29
+ const randomHex = (bytes) => hex(crypto.getRandomValues(new Uint8Array(bytes)));
30
+ /** 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). */
31
+ const trimAscii = (s) => s.replace(/^[ \t]+|[ \t]+$/g, "");
32
+ function cookieValue(req, name) {
33
+ for (const part of (req.headers.get("Cookie") ?? "").split(";")) {
34
+ const i = part.indexOf("=");
35
+ if (i > 0 && trimAscii(part.slice(0, i)) === name) {
36
+ try {
37
+ return decodeURIComponent(trimAscii(part.slice(i + 1)));
38
+ }
39
+ catch {
40
+ // Not valid percent-coding: not ours.
41
+ }
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+ const isHttps = (req) => new URL(req.url).protocol === "https:";
47
+ /** A cookie for this site only: on https it gets the __Host- prefix and Secure (the starter's exact form). */
48
+ export function cookieHeader(req, name, value, maxAgeSec) {
49
+ const https = isHttps(req);
50
+ return `${https ? "__Host-" : ""}${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAgeSec}; HttpOnly; SameSite=Lax${https ? "; Secure" : ""}`;
51
+ }
52
+ const readCookie = (req, name) => cookieValue(req, isHttps(req) ? `__Host-${name}` : name);
53
+ /** What the player reads for a refused passkey sign-in. */
54
+ function passkeyWords(e) {
55
+ if (e.status === 409)
56
+ return "That sign-in was used already. Try again.";
57
+ if (e.status === 401)
58
+ return "The signature does not match. Try again.";
59
+ if (/too old/.test(e.message))
60
+ return "That sign-in is too old. Check the clock of this device, then try again.";
61
+ return "That sign-in could not be read. Try again.";
62
+ }
63
+ const fallbackLimit = memoryLimiter();
64
+ export function createPlayerSignin(options) {
65
+ const base = options.base ?? "/api/signin";
66
+ const signOutPath = options.signOutPath ?? "/api/signout";
67
+ for (const p of [base, signOutPath])
68
+ if (!p.startsWith("/") || p.length < 2 || p.endsWith("/"))
69
+ throw new Error('a path must start with "/" and not end with "/", like "/api/signin"');
70
+ const ways = options.ways ?? WAYS;
71
+ for (const w of ways)
72
+ if (!WAYS.includes(w))
73
+ throw new Error(`no such wallet app: ${String(w)} (the ways are ${WAYS.join(", ")})`);
74
+ const passkeyOn = options.passkey !== false;
75
+ const store = options.store;
76
+ const sessionName = options.cookies?.session ?? "session";
77
+ const starterName = options.cookies?.starter ?? "starter";
78
+ for (const n of [sessionName, starterName])
79
+ if (!/^[A-Za-z0-9_-]{1,40}$/.test(n))
80
+ throw new Error("a cookie's name is letters, digits, _ and - only");
81
+ const sessionMs = Math.max(1, options.sessionDays ?? SESSION_DAYS) * 86_400_000;
82
+ const now = options.now ?? (() => Date.now());
83
+ const limit = options.limit ?? fallbackLimit;
84
+ const memory = memoryLimiter();
85
+ const siteAddress = options.siteAddress && validAddress(options.siteAddress) ? options.siteAddress : EXAMPLE_ADDRESS;
86
+ const report = (e) => {
87
+ try {
88
+ options.onError?.(e);
89
+ }
90
+ catch {
91
+ // nothing more can be done
92
+ }
93
+ };
94
+ const ipOf = (req) => req.headers.get("CF-Connecting-IP") ?? "anyone";
95
+ const visitorOf = async (req) => (options.visitor ? await options.visitor(req) : ipOf(req));
96
+ const person = async (token, req) => (options.personCheck ? options.personCheck(token, req) : true);
97
+ /** Each wallet's options for this request's site: the domain and page come from the address the player opened (or `domain`). */
98
+ function walletsFor(req, at) {
99
+ const url = new URL(req.url);
100
+ const host = options.domain ?? url.host;
101
+ const origin = `${url.protocol}//${host}`;
102
+ const clock = { now: () => at };
103
+ return {
104
+ host,
105
+ berry: { domain: host, uri: `${origin}/`, store: store.requests("berry"), ...clock },
106
+ nautilus: { site: host, siteAddress, store: store.requests("nautilus"), ...clock },
107
+ nanchat: { host, store: store.requests("nanchat"), ...clock },
108
+ requestUrl: (id) => `${origin}${base}/nano/${id}`,
109
+ nautilusUrl: (id) => `${origin}${base}/nautilus?id=${id}`,
110
+ nanchatUrl: `${origin}${base}/nanchat`,
111
+ };
112
+ }
113
+ async function body(req) {
114
+ const text = await readLimited(req, PLAYER_SIGNIN_LIMITS.bodyMax);
115
+ if (text === null)
116
+ return null;
117
+ if (!text)
118
+ return {};
119
+ try {
120
+ const v = JSON.parse(text);
121
+ return v && typeof v === "object" && !Array.isArray(v) ? v : null;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ /** The way a call names; Berry Wallet when it names none (the starter's page sends none for Berry). Null for anything else. */
128
+ const wayOf = (v) => (v === undefined ? (ways.includes("berry") ? "berry" : null) : (ways.find((w) => w === v) ?? null));
129
+ async function newSession(address, via, at) {
130
+ const token = randomHex(32);
131
+ return { token, record: { idHash: await sha256Hex(token), address, createdAt: at, expiresAt: at + sessionMs, via } };
132
+ }
133
+ async function start(req, b, at) {
134
+ const way = wayOf(b.way);
135
+ if (!way)
136
+ return fail(400, "That wallet is not one of this game's.");
137
+ if (!(await limit(`signin|${await visitorOf(req)}`, PLAYER_SIGNIN_LIMITS.signin.limit, PLAYER_SIGNIN_LIMITS.signin.windowMs, at)))
138
+ return fail(429, "Too many sign-ins. Wait a few minutes.");
139
+ if (!(await person(b.turnstile, req)))
140
+ return fail(403, "The person check failed. Try again.");
141
+ const starter = randomHex(32);
142
+ const hash = await sha256Hex(starter);
143
+ const w = walletsFor(req, at);
144
+ let out;
145
+ if (way === "nautilus") {
146
+ const s = await startNautilus(w.nautilus, { starter: hash, answerUrl: w.nautilusUrl });
147
+ out = { way, id: s.id, link: s.link, code: s.code, expiresAt: s.expiresAt };
148
+ }
149
+ else if (way === "nanchat") {
150
+ const s = await startNanchat(w.nanchat, { starter: hash, answerUrl: w.nanchatUrl });
151
+ out = { way, id: s.id, link: s.link, expiresAt: s.expiresAt };
152
+ }
153
+ else {
154
+ const s = await startNanoSignin(w.berry, { starter: hash, requestUrl: w.requestUrl });
155
+ out = { way, id: s.id, link: s.link, walletLink: s.walletLink, expiresAt: s.expiresAt };
156
+ }
157
+ return json(out, 200, { "Set-Cookie": cookieHeader(req, starterName, starter, STARTER_MAX_AGE_S) });
158
+ }
159
+ async function check(req, b, at) {
160
+ const way = wayOf(b.way);
161
+ if (!way)
162
+ return fail(400, "That wallet is not one of this game's.");
163
+ if (typeof b.id !== "string")
164
+ return fail(400, "no request id");
165
+ const cookie = readCookie(req, starterName);
166
+ const starter = cookie && TOKEN_PATTERN.test(cookie) ? await sha256Hex(cookie) : undefined;
167
+ const o = { store: store.requests(way), now: () => at };
168
+ const r = way === "nautilus" ? await checkNautilus(o, b.id, starter) : way === "nanchat" ? await checkNanchat(o, b.id, starter) : await checkNanoSignin(o, b.id, starter);
169
+ if (r.status !== "signedin")
170
+ return json({ status: r.status });
171
+ if (!r.mine || !r.account)
172
+ return json({ status: "elsewhere" });
173
+ // Once: the delivery and the session are one step of the store; a second check of the same request makes no second session.
174
+ const { token, record } = await newSession(r.account, "wallet", at);
175
+ if (!(await store.deliver(way, r.key ?? b.id, record)))
176
+ return json({ status: "used" });
177
+ return json({ status: "signedin", address: r.account }, 200, { "Set-Cookie": cookieHeader(req, sessionName, token, sessionMs / 1000) });
178
+ }
179
+ async function passkey(req, b, at) {
180
+ if (!(await limit(`signin|${await visitorOf(req)}`, PLAYER_SIGNIN_LIMITS.signin.limit, PLAYER_SIGNIN_LIMITS.signin.windowMs, at)))
181
+ return fail(429, "Too many sign-ins. Wait a few minutes.");
182
+ if (!(await person(b.turnstile, req)))
183
+ return fail(403, "The person check failed. Try again.");
184
+ let address;
185
+ try {
186
+ address = await checkSignatureSignin({ domain: walletsFor(req, at).host, useOnce: (k, ttl, a) => store.useOnce(k, ttl, a), now: () => at }, { address: b.address, at: Number(b.at), signature: b.signature });
187
+ }
188
+ catch (e) {
189
+ if (e instanceof SigninError)
190
+ return fail(e.status, passkeyWords(e));
191
+ throw e;
192
+ }
193
+ const { token, record } = await newSession(address, "passkey", at);
194
+ await store.createSession(record);
195
+ return json({ status: "signedin", address }, 200, { "Set-Cookie": cookieHeader(req, sessionName, token, sessionMs / 1000) });
196
+ }
197
+ /** Berry Wallet's GET and POST. They come from the wallet app: no cookie, no Origin. */
198
+ async function berry(req, id, at) {
199
+ if (options.sharedPaths && !(await store.requests("berry").get(id)))
200
+ return null;
201
+ const w = walletsFor(req, at);
202
+ if (req.method === "GET") {
203
+ const request = await readNanoSignin(w.berry, id, w.requestUrl);
204
+ return request ? json(request) : json(nanoSigninRefusal("This sign-in expired."), 404);
205
+ }
206
+ const b = await body(req);
207
+ if (!b)
208
+ return json(nanoSigninRefusal("This answer is not readable."), 401);
209
+ try {
210
+ await answerNanoSignin(w.berry, id, b, w.requestUrl);
211
+ // Berry Wallet shows its success screen only for a 204 with no body.
212
+ return new Response(null, { status: NANO_SIGNIN_ACCEPTED, headers: { "Cache-Control": "no-store" } });
213
+ }
214
+ catch (e) {
215
+ if (e instanceof WalletRefused)
216
+ return json(nanoSigninRefusal(e.message), 401);
217
+ throw e;
218
+ }
219
+ }
220
+ async function nautilus(req, at) {
221
+ const id = new URL(req.url).searchParams.get("id") ?? "";
222
+ if (options.sharedPaths && !(await store.requests("nautilus").get(id)))
223
+ return null;
224
+ const w = walletsFor(req, at);
225
+ const b = await body(req);
226
+ if (!b)
227
+ return json(nautilusReply(w.host, "That answer could not be read."));
228
+ try {
229
+ await answerNautilus(w.nautilus, id, b);
230
+ return json(nautilusReply(w.host));
231
+ }
232
+ catch (e) {
233
+ if (e instanceof WalletRefused)
234
+ return json(nautilusReply(w.host, e.message));
235
+ throw e;
236
+ }
237
+ }
238
+ /** NanChat's answer: its relay server posts every one, so the limits are totals and per nonce, never per visitor. */
239
+ async function nanchat(req, at) {
240
+ const w = walletsFor(req, at);
241
+ const refuse = (words) => json(nanchatReply(w.host, words));
242
+ if (!memory("nanchat", PLAYER_SIGNIN_LIMITS.nanchatRoute.limit, PLAYER_SIGNIN_LIMITS.nanchatRoute.windowMs, at))
243
+ return refuse("too many sign-ins at once. Wait a minute.");
244
+ const b = await body(req);
245
+ if (!b)
246
+ return refuse("that answer could not be read.");
247
+ const nonce = nanchatNonce(w.host, b.message);
248
+ if (nonce && typeof b.account === "string" && typeof b.signature === "string" && /^[0-9A-Fa-f]{128}$/.test(b.signature)) {
249
+ if (!(await limit("nanchat|all", PLAYER_SIGNIN_LIMITS.nanchatAll.limit, PLAYER_SIGNIN_LIMITS.nanchatAll.windowMs, at)))
250
+ return refuse("too many sign-ins at once. Wait a few minutes.");
251
+ if (!(await limit(`nanchat|${nonce}`, PLAYER_SIGNIN_LIMITS.nanchatNonce.limit, PLAYER_SIGNIN_LIMITS.nanchatNonce.windowMs, at)))
252
+ return refuse(`too many tries for this sign-in. Start again on ${w.host}.`);
253
+ }
254
+ try {
255
+ await answerNanchat(w.nanchat, b);
256
+ return json(nanchatReply(w.host));
257
+ }
258
+ catch (e) {
259
+ if (e instanceof WalletRefused)
260
+ return refuse(e.message);
261
+ throw e;
262
+ }
263
+ }
264
+ async function sessionOf(req) {
265
+ const token = readCookie(req, sessionName);
266
+ if (!token || !TOKEN_PATTERN.test(token))
267
+ return null;
268
+ const s = await store.session(await sha256Hex(token), now());
269
+ return s ? { address: s.address, createdAt: s.createdAt, via: viaOf(s.via) } : null;
270
+ }
271
+ async function signOut(req) {
272
+ const token = readCookie(req, sessionName);
273
+ if (token && TOKEN_PATTERN.test(token))
274
+ await store.endSession(await sha256Hex(token));
275
+ return cookieHeader(req, sessionName, "", 0);
276
+ }
277
+ async function handle(req) {
278
+ const url = new URL(req.url);
279
+ const path = url.pathname;
280
+ const berryPath = path.startsWith(`${base}/nano/`) ? /^[0-9a-f]{32}$/.exec(path.slice(base.length + 6))?.[0] : undefined;
281
+ const browserRoutes = [`${base}/start`, `${base}/check`, `${base}/passkey`, signOutPath];
282
+ const mine = browserRoutes.includes(path) || berryPath !== undefined || path === `${base}/nautilus` || path === `${base}/nanchat`;
283
+ if (!mine)
284
+ return null;
285
+ if (path === `${base}/passkey` && !passkeyOn)
286
+ return null;
287
+ try {
288
+ if (options.domain && url.host !== options.domain)
289
+ return fail(403, `This sign-in belongs to ${options.domain}.`);
290
+ const at = now();
291
+ const ip = ipOf(req);
292
+ // The wallet apps' routes: no cookie and no Origin (an app, or NanChat's relay server, posts them: not a browser page).
293
+ if (berryPath !== undefined) {
294
+ if (req.method !== "GET" && req.method !== "POST")
295
+ return fail(405, "Not allowed.", {}, { Allow: "GET, POST" });
296
+ if (!ways.includes("berry"))
297
+ return null;
298
+ if (!memory(`wallet|${ip}`, PLAYER_SIGNIN_LIMITS.wallet.limit, PLAYER_SIGNIN_LIMITS.wallet.windowMs, at))
299
+ return fail(429, "Too many requests.");
300
+ return await berry(req, berryPath, at);
301
+ }
302
+ if (path === `${base}/nautilus`) {
303
+ if (req.method !== "POST" || !ways.includes("nautilus"))
304
+ return null;
305
+ // Nautilus shows the words of a refusal only with HTTP 200.
306
+ if (!memory(`wallet|${ip}`, PLAYER_SIGNIN_LIMITS.wallet.limit, PLAYER_SIGNIN_LIMITS.wallet.windowMs, at))
307
+ return json(nautilusReply(walletsFor(req, at).host, "Too many requests. Wait a minute."));
308
+ return await nautilus(req, at);
309
+ }
310
+ if (path === `${base}/nanchat`) {
311
+ if (req.method !== "POST" || !ways.includes("nanchat"))
312
+ return null;
313
+ return await nanchat(req, at);
314
+ }
315
+ // The browser's own calls: a POST from the game's own page.
316
+ if (req.method !== "POST")
317
+ return fail(405, "Not allowed.", {}, { Allow: "POST" });
318
+ // Read on a copy: a call that is the admin's goes back to the game whole.
319
+ const b = await body(options.sharedPaths ? req.clone() : req);
320
+ if (b && b.scope === "admin")
321
+ return options.sharedPaths ? null : fail(400, "This game has no such sign-in.");
322
+ if (!sameOrigin(req))
323
+ return fail(403, "This call must come from the game's own page.");
324
+ if (path === signOutPath) {
325
+ // A body that is not JSON (an older page) signs out too.
326
+ return json({ ok: true }, 200, { "Set-Cookie": await signOut(req) });
327
+ }
328
+ if (!b)
329
+ return fail(400, "That request is not readable.");
330
+ if (path === `${base}/check`) {
331
+ if (!memory(`poll|${ip}`, PLAYER_SIGNIN_LIMITS.poll.limit, PLAYER_SIGNIN_LIMITS.poll.windowMs, at))
332
+ return fail(429, "Too many requests. Wait a minute.");
333
+ return await check(req, b, at);
334
+ }
335
+ if (path === `${base}/start`)
336
+ return await start(req, b, at);
337
+ return await passkey(req, b, at);
338
+ }
339
+ catch (e) {
340
+ report(e);
341
+ return fail(500, "Something went wrong. Try again.");
342
+ }
343
+ }
344
+ return {
345
+ base,
346
+ handle,
347
+ async player(req) {
348
+ return (await sessionOf(req))?.address ?? null;
349
+ },
350
+ session: sessionOf,
351
+ async signIn(req, address, via) {
352
+ if (!validAddress(address))
353
+ throw new Error("a session is for a whole Nano address");
354
+ if (!["wallet", "passkey", "nanoterra"].includes(via))
355
+ throw new Error("say how the player signed in: wallet, passkey or nanoterra");
356
+ const { token, record } = await newSession(address, via, now());
357
+ await store.createSession(record);
358
+ return cookieHeader(req, sessionName, token, sessionMs / 1000);
359
+ },
360
+ signOut,
361
+ forget: (player) => store.forget(player),
362
+ clean: (at = now()) => store.clean(at),
363
+ };
364
+ }
@@ -0,0 +1,4 @@
1
+ /** True for a preview address that a game leaves for its own domain: `*.workers.dev`. */
2
+ export declare function previewHost(hostname: string): boolean;
3
+ /** What the admin says on a preview address of a game that offers its players a passkey. */
4
+ export declare const PASSKEY_PREVIEW_NOTE = "Passkeys turn on when your game has its own domain.";
@@ -0,0 +1,10 @@
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
+ /** True for a preview address that a game leaves for its own domain: `*.workers.dev`. */
5
+ export function previewHost(hostname) {
6
+ const host = hostname.toLowerCase().replace(/\.$/, "");
7
+ return host === "workers.dev" || host.endsWith(".workers.dev");
8
+ }
9
+ /** What the admin says on a preview address of a game that offers its players a passkey. */
10
+ export const PASSKEY_PREVIEW_NOTE = "Passkeys turn on when your game has its own domain.";
@@ -0,0 +1,9 @@
1
+ export { EXAMPLE_ADDRESS, cookieHeader, createPlayerSignin, sha256Hex } from "./handler.js";
2
+ export type { PlayerSignin, PlayerSigninOptions } from "./handler.js";
3
+ export { createMemoryPlayerSignin } from "./memory.js";
4
+ export type { MemoryPlayerSignin } from "./memory.js";
5
+ export { checkScheme, schemeVector } from "./scheme.js";
6
+ export { PASSKEY_PREVIEW_NOTE, previewHost } from "./host.js";
7
+ export type { PasskeyScheme } from "./scheme.js";
8
+ export { PLAYER_SIGNIN_LIMITS, SESSION_DAYS, STARTER_MAX_AGE_S, VIAS, WAYS, viaOf } from "./types.js";
9
+ export type { PlayerSession, PlayerSigninStore, RequestStoreLike, SessionRecord, SigninRequest, Via, Way } from "./types.js";
@@ -0,0 +1,15 @@
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
+ export { EXAMPLE_ADDRESS, cookieHeader, createPlayerSignin, sha256Hex } from "./handler.js";
12
+ export { createMemoryPlayerSignin } from "./memory.js";
13
+ export { checkScheme, schemeVector } from "./scheme.js";
14
+ export { PASSKEY_PREVIEW_NOTE, previewHost } from "./host.js";
15
+ export { PLAYER_SIGNIN_LIMITS, SESSION_DAYS, STARTER_MAX_AGE_S, VIAS, WAYS, viaOf } from "./types.js";
@@ -0,0 +1,15 @@
1
+ import { type PlayerSigninStore, type SessionRecord, type SigninRequest, type Way } from "./types.js";
2
+ type RequestRow = SigninRequest & {
3
+ way: Way;
4
+ delivered: boolean;
5
+ };
6
+ export type MemoryPlayerSignin = PlayerSigninStore & {
7
+ readonly requestRows: Map<string, RequestRow>;
8
+ readonly sessionRows: Map<string, SessionRecord>;
9
+ readonly onceRows: Map<string, {
10
+ address: string;
11
+ until: number;
12
+ }>;
13
+ };
14
+ export declare function createMemoryPlayerSignin(): MemoryPlayerSignin;
15
+ export {};
@@ -0,0 +1,101 @@
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
+ import { WAYS } from "./types.js";
4
+ const copy = (v) => structuredClone(v);
5
+ export function createMemoryPlayerSignin() {
6
+ const requestRows = new Map();
7
+ const sessionRows = new Map();
8
+ const onceRows = new Map();
9
+ const strip = ({ way: _way, delivered: _delivered, ...r }) => copy(r);
10
+ const stores = new Map();
11
+ for (const way of WAYS) {
12
+ stores.set(way, {
13
+ async create(key, r) {
14
+ if (requestRows.has(key))
15
+ throw new Error("a request with this key exists");
16
+ requestRows.set(key, { ...copy(r), way, delivered: false });
17
+ },
18
+ async get(key) {
19
+ const r = requestRows.get(key);
20
+ return r && r.way === way ? strip(r) : null;
21
+ },
22
+ async find(id) {
23
+ for (const [key, r] of requestRows)
24
+ if (r.way === way && r.id === id)
25
+ return { key, request: strip(r) };
26
+ return null;
27
+ },
28
+ async finish(key, account) {
29
+ const r = requestRows.get(key);
30
+ if (!r || r.way !== way || r.status !== "waiting")
31
+ return false;
32
+ r.status = "signedin";
33
+ r.account = account;
34
+ return true;
35
+ },
36
+ });
37
+ }
38
+ return {
39
+ requestRows,
40
+ sessionRows,
41
+ onceRows,
42
+ requests: (way) => {
43
+ const s = stores.get(way);
44
+ if (!s)
45
+ throw new Error(`no such way: ${String(way)}`);
46
+ return s;
47
+ },
48
+ async deliver(way, key, session) {
49
+ const r = requestRows.get(key);
50
+ if (!r || r.way !== way || r.status !== "signedin" || r.delivered)
51
+ return false;
52
+ r.delivered = true;
53
+ sessionRows.set(session.idHash, copy(session));
54
+ return true;
55
+ },
56
+ async createSession(session) {
57
+ if (sessionRows.has(session.idHash))
58
+ throw new Error("a session with this id exists");
59
+ sessionRows.set(session.idHash, copy(session));
60
+ },
61
+ async session(idHash, now) {
62
+ const s = sessionRows.get(idHash);
63
+ return s && s.expiresAt > now ? copy(s) : null;
64
+ },
65
+ async endSession(idHash) {
66
+ sessionRows.delete(idHash);
67
+ },
68
+ async useOnce(key, ttlMs, address) {
69
+ const now = Date.now();
70
+ const had = onceRows.get(key);
71
+ if (had && had.until > now)
72
+ return false;
73
+ onceRows.set(key, { address, until: now + ttlMs });
74
+ return true;
75
+ },
76
+ async forget(address) {
77
+ let n = 0;
78
+ for (const [k, s] of sessionRows)
79
+ if (s.address === address && sessionRows.delete(k))
80
+ n++;
81
+ for (const [k, r] of requestRows)
82
+ if (r.account === address && requestRows.delete(k))
83
+ n++;
84
+ for (const [k, o] of onceRows)
85
+ if (o.address === address && onceRows.delete(k))
86
+ n++;
87
+ return n;
88
+ },
89
+ async clean(now) {
90
+ for (const [k, r] of requestRows)
91
+ if (r.expiresAt < now - 2 * 3_600_000)
92
+ requestRows.delete(k);
93
+ for (const [k, s] of sessionRows)
94
+ if (s.expiresAt <= now)
95
+ sessionRows.delete(k);
96
+ for (const [k, o] of onceRows)
97
+ if (o.until <= now)
98
+ onceRows.delete(k);
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,9 @@
1
+ export { mountPlayerSignin } from "./mount.js";
2
+ export type { MountedPlayerSignin, PlayerSigninMountOptions } from "./mount.js";
3
+ export { hostFits, passkeyName, signInView } from "./view.js";
4
+ export { PASSKEY_PREVIEW_NOTE, previewHost } from "../host.js";
5
+ export type { PlayerSigninView, PlayerSigninViewOptions, SignedIn } from "./view.js";
6
+ export { faultWords } from "./words.js";
7
+ export type { FaultWords } from "./words.js";
8
+ export { readMemo } from "./memo.js";
9
+ export type { SigninMemo } from "./memo.js";
@@ -0,0 +1,7 @@
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
+ export { mountPlayerSignin } from "./mount.js";
4
+ export { hostFits, passkeyName, signInView } from "./view.js";
5
+ export { PASSKEY_PREVIEW_NOTE, previewHost } from "../host.js";
6
+ export { faultWords } from "./words.js";
7
+ export { readMemo } from "./memo.js";
@@ -0,0 +1,12 @@
1
+ import type { Way } from "../types.js";
2
+ export type SigninMemo = {
3
+ ids: string[];
4
+ unopened: string[];
5
+ last: Way | "passkey" | null;
6
+ };
7
+ export declare function readMemo(rpId: string): SigninMemo;
8
+ /** 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). */
9
+ export declare function rememberMade(rpId: string, id: string): void;
10
+ /** A passkey signed the player in. Returns true when it was made here and never opened before: a new account. */
11
+ export declare function rememberOpened(rpId: string, id: string): boolean;
12
+ export declare function rememberLast(rpId: string, last: Way | "passkey"): void;
@@ -0,0 +1,41 @@
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
+ const KEY = (rpId) => `n133.player-signin.v1:${rpId}`;
5
+ const isId = (x) => typeof x === "string" && /^[A-Za-z0-9_-]{8,700}$/.test(x);
6
+ const LAST = new Set(["passkey", "berry", "nanchat", "nautilus"]);
7
+ export function readMemo(rpId) {
8
+ try {
9
+ const m = JSON.parse(globalThis.localStorage?.getItem(KEY(rpId)) ?? "null");
10
+ const ids = Array.isArray(m?.ids) ? m.ids.filter(isId).slice(-8) : [];
11
+ const unopened = Array.isArray(m?.unopened) ? m.unopened.filter((x) => isId(x) && ids.includes(x)) : [];
12
+ const last = typeof m?.last === "string" && LAST.has(m.last) ? m.last : null;
13
+ return { ids, unopened, last };
14
+ }
15
+ catch {
16
+ return { ids: [], unopened: [], last: null };
17
+ }
18
+ }
19
+ function write(rpId, m) {
20
+ try {
21
+ globalThis.localStorage?.setItem(KEY(rpId), JSON.stringify(m));
22
+ }
23
+ catch {
24
+ // Storage refused (a private window): nothing is remembered.
25
+ }
26
+ }
27
+ /** 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). */
28
+ export function rememberMade(rpId, id) {
29
+ const m = readMemo(rpId);
30
+ write(rpId, { ...m, ids: [...m.ids.filter((x) => x !== id), id].slice(-8), unopened: [...m.unopened.filter((x) => x !== id), id] });
31
+ }
32
+ /** A passkey signed the player in. Returns true when it was made here and never opened before: a new account. */
33
+ export function rememberOpened(rpId, id) {
34
+ const m = readMemo(rpId);
35
+ const fresh = m.unopened.includes(id);
36
+ write(rpId, { ids: [...m.ids.filter((x) => x !== id), id].slice(-8), unopened: m.unopened.filter((x) => x !== id), last: "passkey" });
37
+ return fresh;
38
+ }
39
+ export function rememberLast(rpId, last) {
40
+ write(rpId, { ...readMemo(rpId), last });
41
+ }
@@ -0,0 +1,21 @@
1
+ import { type PlayerSigninViewOptions, type SignedIn } from "./view.js";
2
+ export type PlayerSigninMountOptions = PlayerSigninViewOptions & {
3
+ /** The sheet's title. Default "Sign in". */
4
+ title?: string;
5
+ /** How long "Signed in as …" shows before the sheet closes, in ms. Default 900. */
6
+ closeAfterMs?: number;
7
+ /**
8
+ * Where the keyboard focus goes when the sheet closes. "game" (the default): out of the sheet, to the page, so the game's keys reach the game. "back":
9
+ * to the element that had it when the sheet opened (a page with keys of its own).
10
+ */
11
+ focusAfterClose?: "game" | "back";
12
+ };
13
+ export type MountedPlayerSignin = {
14
+ /** Opens the sheet. Resolves with the player when they sign in, or null when the sheet closes first. A second call while it is open gets the same promise. */
15
+ open(): Promise<SignedIn | null>;
16
+ close(): void;
17
+ readonly isOpen: boolean;
18
+ /** Removes the sheet and stops every wait. */
19
+ destroy(): void;
20
+ };
21
+ export declare function mountPlayerSignin(root: HTMLElement, options: PlayerSigninMountOptions): MountedPlayerSignin;