@nano133/nanoterra 0.1.0 → 0.3.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 (106) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +269 -12
  3. package/ai/SPEC.md +7 -2
  4. package/dist/admin/adapters/d1.d.ts +27 -0
  5. package/dist/admin/adapters/d1.js +73 -0
  6. package/dist/admin/adapters/firestore.d.ts +40 -0
  7. package/dist/admin/adapters/firestore.js +53 -0
  8. package/dist/admin/amount.d.ts +5 -0
  9. package/dist/admin/amount.js +11 -0
  10. package/dist/admin/conformance.d.ts +132 -0
  11. package/dist/admin/conformance.js +467 -0
  12. package/dist/admin/guard.d.ts +38 -0
  13. package/dist/admin/guard.js +57 -0
  14. package/dist/admin/handler.d.ts +63 -0
  15. package/dist/admin/handler.js +170 -0
  16. package/dist/admin/http.d.ts +17 -0
  17. package/dist/admin/http.js +76 -0
  18. package/dist/admin/index.d.ts +13 -0
  19. package/dist/admin/index.js +14 -0
  20. package/dist/admin/memory.d.ts +32 -0
  21. package/dist/admin/memory.js +40 -0
  22. package/dist/admin/panel/api.d.ts +11 -0
  23. package/dist/admin/panel/api.js +33 -0
  24. package/dist/admin/panel/dom.d.ts +7 -0
  25. package/dist/admin/panel/dom.js +33 -0
  26. package/dist/admin/panel/gameCard.d.ts +4 -0
  27. package/dist/admin/panel/gameCard.js +56 -0
  28. package/dist/admin/panel/index.d.ts +10 -0
  29. package/dist/admin/panel/index.js +13 -0
  30. package/dist/admin/panel/mount.d.ts +30 -0
  31. package/dist/admin/panel/mount.js +147 -0
  32. package/dist/admin/panel/shelf.d.ts +24 -0
  33. package/dist/admin/panel/shelf.js +271 -0
  34. package/dist/admin/panel/shelfData.d.ts +37 -0
  35. package/dist/admin/panel/shelfData.js +68 -0
  36. package/dist/admin/panel/signins.d.ts +6 -0
  37. package/dist/admin/panel/signins.js +69 -0
  38. package/dist/admin/store.d.ts +121 -0
  39. package/dist/admin/store.js +3 -0
  40. package/dist/admin/summary.d.ts +39 -0
  41. package/dist/admin/summary.js +85 -0
  42. package/dist/admin/switches.d.ts +58 -0
  43. package/dist/admin/switches.js +61 -0
  44. package/dist/character/bases/bee.js +7 -5
  45. package/dist/character/bases/spec.d.ts +7 -0
  46. package/dist/character/create.d.ts +15 -0
  47. package/dist/character/create.js +69 -2
  48. package/dist/character/ctx.d.ts +12 -2
  49. package/dist/character/index.d.ts +2 -0
  50. package/dist/character/index.js +2 -0
  51. package/dist/character/make.js +2 -1
  52. package/dist/character/preview.d.ts +32 -0
  53. package/dist/character/preview.js +112 -0
  54. package/dist/character/sockets.d.ts +37 -0
  55. package/dist/character/sockets.js +66 -0
  56. package/dist/looks/change.d.ts +8 -4
  57. package/dist/looks/change.js +25 -3
  58. package/dist/looks/index.d.ts +3 -3
  59. package/dist/looks/index.js +2 -2
  60. package/dist/looks/json.d.ts +2 -2
  61. package/dist/looks/json.js +6 -4
  62. package/dist/looks/look.d.ts +29 -3
  63. package/dist/looks/look.js +60 -4
  64. package/dist/model/check.d.ts +59 -0
  65. package/dist/model/check.js +681 -0
  66. package/dist/model/index.d.ts +2 -0
  67. package/dist/model/index.js +5 -0
  68. package/dist/model/limits.d.ts +103 -0
  69. package/dist/model/limits.js +132 -0
  70. package/package.json +11 -3
  71. package/src/admin/adapters/d1.ts +94 -0
  72. package/src/admin/adapters/firestore.ts +78 -0
  73. package/src/admin/amount.ts +14 -0
  74. package/src/admin/conformance.ts +605 -0
  75. package/src/admin/guard.ts +86 -0
  76. package/src/admin/handler.ts +200 -0
  77. package/src/admin/http.ts +74 -0
  78. package/src/admin/index.ts +21 -0
  79. package/src/admin/memory.ts +59 -0
  80. package/src/admin/panel/api.ts +37 -0
  81. package/src/admin/panel/dom.ts +31 -0
  82. package/src/admin/panel/gameCard.ts +63 -0
  83. package/src/admin/panel/index.ts +18 -0
  84. package/src/admin/panel/mount.ts +227 -0
  85. package/src/admin/panel/shelf.ts +361 -0
  86. package/src/admin/panel/shelfData.ts +87 -0
  87. package/src/admin/panel/signins.ts +68 -0
  88. package/src/admin/panel/style.css +551 -0
  89. package/src/admin/store.ts +98 -0
  90. package/src/admin/summary.ts +103 -0
  91. package/src/admin/switches.ts +74 -0
  92. package/src/character/bases/bee.ts +7 -5
  93. package/src/character/bases/spec.ts +4 -0
  94. package/src/character/create.ts +73 -2
  95. package/src/character/ctx.ts +17 -2
  96. package/src/character/index.ts +2 -0
  97. package/src/character/make.ts +2 -1
  98. package/src/character/preview.ts +148 -0
  99. package/src/character/sockets.ts +81 -0
  100. package/src/looks/change.ts +25 -6
  101. package/src/looks/index.ts +3 -3
  102. package/src/looks/json.ts +6 -4
  103. package/src/looks/look.ts +66 -5
  104. package/src/model/check.ts +624 -0
  105. package/src/model/index.ts +5 -0
  106. package/src/model/limits.ts +193 -0
@@ -0,0 +1,467 @@
1
+ // The conformance suite: every game that uses the admin handler runs this against its OWN session code and store, in its own tests. It proves
2
+ // the whole chain (the cookie, the session lookup, the guard, the routes), not only the pure guard: a game that reads the wrong cookie, or that
3
+ // forgets to pass its owner, fails here.
4
+ //
5
+ // import { adminConformance } from "@nano133/nanoterra/admin/conformance";
6
+ // for (const c of adminConformance(host)) it(c.name, c.run); // vitest, node:test, any runner
7
+ //
8
+ // `host.request(who, ...)` sends the request as that kind of visitor. The game makes each kind from its own sessions (see `Who`). The suite asks
9
+ // only for statuses and for the words "reason" and "error"; it never reads a cookie.
10
+ import { ORDERS_SHOWN } from "./summary.js";
11
+ const REFUSALS = [
12
+ { who: "signed_out", status: 401, words: "a visitor who is signed out" },
13
+ { who: "game_login", status: 401, words: "a login that was made for the game's players" },
14
+ { who: "game_token_as_admin", status: 401, words: "the token of a player's login put in the admin cookie" },
15
+ { who: "nanoterra_login", status: 403, reason: "nanoterra", words: "a Nanoterra sign-in (it names an address but proves no key)" },
16
+ { who: "unknown_via_login", status: 403, words: "an admin login that was made some way the game does not know (it must fail closed)" },
17
+ { who: "other_wallet", status: 403, reason: "other_address", words: "a wallet that is not the owner's" },
18
+ { who: "no_address", status: 403, reason: "no_address", words: "anyone, while the game has no owner address" },
19
+ { who: "expired_login", status: 401, words: "an admin login that the game's own store has expired (made less than a day ago)" },
20
+ { who: "signed_out_login", status: 401, words: "an admin login that the game's own sign-out has ended" },
21
+ { who: "old_valid_row", status: 401, words: "an admin login that is more than a day old but still valid in the game's store (only the guard's age check refuses it)" },
22
+ ];
23
+ function check(ok, message) {
24
+ if (!ok)
25
+ throw new Error(message);
26
+ }
27
+ async function read(res) {
28
+ const text = await res.text();
29
+ let json = null;
30
+ try {
31
+ const v = JSON.parse(text);
32
+ if (v && typeof v === "object" && !Array.isArray(v))
33
+ json = v;
34
+ }
35
+ catch {
36
+ // not JSON
37
+ }
38
+ return { status: res.status, text, json };
39
+ }
40
+ /** A refusal carries words and a reason and nothing else: no data of the game. */
41
+ const REFUSAL_KEYS = new Set(["error", "reason", "signedInAs"]);
42
+ /** Words of the game's data that a refusal or an answer to a visitor must never hold. */
43
+ const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"warnings"/;
44
+ export function adminConformance(host) {
45
+ const cases = [];
46
+ const add = (name, run) => cases.push({ name, run });
47
+ const good = (extra = {}) => ({ Origin: host.origin, "Content-Type": "application/json", ...extra });
48
+ const body = (r) => (r.method === "POST" ? "{}" : undefined);
49
+ const NOT_OWNER = REFUSALS.map((r) => r.who);
50
+ check(host.routes.length > 0, "the host lists no routes");
51
+ /**
52
+ * Sends a request and looks at the HEADERS of the answer: every answer to a request for the admin is `no-store` and has no CORS header, whatever the method and the
53
+ * status (a preflight answer that allows an origin, or a cached refusal, is a fault). `strict: false` is for paths that the game's own router may answer (a plain 404).
54
+ */
55
+ async function send(who, req, strict = true) {
56
+ const res = await host.request(who, req);
57
+ let cors = "";
58
+ res.headers.forEach((v, k) => {
59
+ if (k.toLowerCase().startsWith("access-control-"))
60
+ cors = `${k}: ${v}`;
61
+ });
62
+ check(cors === "", `${req.method} ${req.path} as ${who}: the answer has a CORS header (${cors})`);
63
+ if (strict)
64
+ check((res.headers.get("cache-control") ?? "").includes("no-store"), `${req.method} ${req.path} as ${who}: the answer (${res.status}) is not "Cache-Control: no-store"`);
65
+ return res;
66
+ }
67
+ for (const r of REFUSALS) {
68
+ add(`every route refuses ${r.words} (${r.status}${r.reason ? `, ${r.reason}` : ""}) and gives it no data`, async () => {
69
+ for (const route of host.routes) {
70
+ const got = await read(await send(r.who, { method: route.method, path: route.path, headers: good(), body: body(route) }));
71
+ const here = `${route.method} ${route.path} as ${r.who}`;
72
+ check(got.status === r.status, `${here}: expected ${r.status}, got ${got.status}`);
73
+ if (r.reason)
74
+ check(got.json?.reason === r.reason, `${here}: expected reason "${r.reason}", got ${JSON.stringify(got.json?.reason)}`);
75
+ check(got.json !== null && Object.keys(got.json).every((k) => REFUSAL_KEYS.has(k)), `${here}: a refusal must carry only error, reason and signedInAs, got ${got.text.slice(0, 120)}`);
76
+ }
77
+ });
78
+ }
79
+ add("OPTIONS and HEAD on every route are refused like any other method, with no CORS answer: no preflight is ever answered for a visitor", async () => {
80
+ for (const r of REFUSALS) {
81
+ for (const route of host.routes) {
82
+ for (const method of ["OPTIONS", "HEAD"]) {
83
+ const headers = method === "OPTIONS" ? good({ "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "content-type" }) : good();
84
+ const got = await send(r.who, { method, path: route.path, headers });
85
+ check(got.status === r.status, `${method} ${route.path} as ${r.who}: expected ${r.status}, got ${got.status}`);
86
+ }
87
+ }
88
+ }
89
+ });
90
+ add("an unknown path under the base is refused like any admin path (so the routes are not leaked): the same refusal for a visitor, 404 for the owner", async () => {
91
+ const path = `${host.basePath}/no-such-page`;
92
+ for (const r of REFUSALS) {
93
+ const got = await send(r.who, { method: "GET", path });
94
+ check(got.status === r.status, `GET ${path} as ${r.who}: expected ${r.status}, got ${got.status}`);
95
+ }
96
+ const owner = await send("owner", { method: "GET", path });
97
+ check(owner.status === 404, `GET ${path} as owner: expected 404, got ${owner.status}`);
98
+ });
99
+ add("the game's other routes under the base (the ones its own router serves) refuse every visitor who is not the owner, with a refusal that carries no data", async () => {
100
+ for (const route of host.otherRoutes ?? []) {
101
+ for (const who of NOT_OWNER) {
102
+ const got = await read(await send(who, { method: route.method, path: route.path, headers: good(), body: body(route) }, false));
103
+ const here = `${route.method} ${route.path} as ${who}`;
104
+ check(got.status === 401 || got.status === 403, `${here}: expected 401 or 403, got ${got.status}`);
105
+ check(!DATA_WORDS.test(got.text), `${here}: the answer holds the game's data`);
106
+ check(got.json !== null && Object.keys(got.json).every((k) => REFUSAL_KEYS.has(k)), `${here}: a refusal must carry only error, reason and signedInAs, got ${got.text.slice(0, 120)}`);
107
+ }
108
+ }
109
+ });
110
+ /** Other spellings of one admin path: other case, a slash, a doubled slash, a percent code, a backslash, a dot segment. */
111
+ const spellings = (path) => {
112
+ const last = path.slice(path.lastIndexOf("/") + 1);
113
+ const base = host.basePath;
114
+ return [
115
+ path.toUpperCase(),
116
+ `${path}/`,
117
+ path.replace(`/${last}`, `//${last}`),
118
+ path.slice(0, -1) + `%${path.charCodeAt(path.length - 1).toString(16)}`,
119
+ path.replace(`/${last}`, `/x/../${last}`),
120
+ `${base}%2f${last}`,
121
+ `${base}\\${last}`,
122
+ `${base}%5c${last}`,
123
+ `${base.replace(/^\/(\w+)\//, "/$1%2f")}/${last}`,
124
+ `${base.replace(/^\/(\w)/, (_, c) => `/%${c.charCodeAt(0).toString(16)}`)}/${last}`,
125
+ ];
126
+ };
127
+ add("other spellings of EVERY admin path never open anything for a visitor, with any method", async () => {
128
+ const seen = new Set();
129
+ for (const route of host.routes) {
130
+ for (const path of spellings(route.path)) {
131
+ if (seen.has(path))
132
+ continue;
133
+ seen.add(path);
134
+ for (const who of NOT_OWNER) {
135
+ for (const method of new Set([route.method, "GET", "POST", "OPTIONS", "HEAD"])) {
136
+ const got = await read(await send(who, { method, path, headers: good(), body: body({ method }) }, false));
137
+ check([401, 403, 404, 405].includes(got.status), `${method} ${path} as ${who}: expected 401, 403, 404 or 405, got ${got.status}`);
138
+ check(!DATA_WORDS.test(got.text), `${method} ${path} as ${who}: the answer holds the game's data`);
139
+ }
140
+ }
141
+ }
142
+ }
143
+ });
144
+ add("a request from another page never gets a CORS header, and never the data: a foreign Origin (another site, 'null', a sibling subdomain) on every route, with every method, for the owner and for every visitor", async () => {
145
+ const own = new URL(host.origin);
146
+ const origins = ["https://evil.example", "null", `${own.protocol}//evil.${own.host}`, `${own.protocol}//${own.host.replace(/^[^.]+\./, "sibling.")}`, `${own.protocol}//${own.host}.evil.example`];
147
+ const WHO = ["owner", ...NOT_OWNER];
148
+ for (const route of host.routes) {
149
+ for (const origin of origins) {
150
+ for (const who of WHO) {
151
+ for (const method of new Set([route.method, "GET", "OPTIONS", "HEAD"])) {
152
+ const got = await read(await send(who, { method, path: route.path, headers: { Origin: origin, "Content-Type": "application/json" }, body: body({ method }) }));
153
+ const here = `${method} ${route.path} as ${who} with Origin ${origin}`;
154
+ check([401, 403, 405].includes(got.status), `${here}: a foreign page must get 401, 403 or 405, got ${got.status}`);
155
+ check(!DATA_WORDS.test(got.text), `${here}: the answer holds the game's data`);
156
+ }
157
+ }
158
+ }
159
+ // Sec-Fetch-Site says the same: cross-site and same-site (a sibling subdomain has the game's cookies) are not the game's own page.
160
+ for (const site of ["cross-site", "same-site"]) {
161
+ const got = await read(await send("owner", { method: route.method, path: route.path, headers: { "Sec-Fetch-Site": site, "Content-Type": "application/json" }, body: body(route) }));
162
+ check(got.status === 403, `${route.method} ${route.path} as owner with Sec-Fetch-Site ${site}: expected 403, got ${got.status}`);
163
+ }
164
+ }
165
+ });
166
+ add("an old login that is still valid in the store is refused EVERY time it is used, not only the first: a game must not renew a login on each read", async () => {
167
+ for (const route of host.routes) {
168
+ for (const again of [false, true, true]) {
169
+ const got = await read(await send("old_valid_row", { method: route.method, path: route.path, headers: good(), body: body(route), again }));
170
+ check(got.status === 401, `${route.method} ${route.path} as old_valid_row${again ? " (the same login again)" : ""}: expected 401, got ${got.status}`);
171
+ }
172
+ }
173
+ });
174
+ add("a visitor is refused even right after the owner was served on the same URL: an answer is never kept and shown to the next visitor (no cache of answers by URL)", async () => {
175
+ for (const route of host.routes.filter((r) => r.method === "GET")) {
176
+ for (const round of [1, 2]) {
177
+ const served = await send("owner", { method: "GET", path: route.path, headers: good() });
178
+ check(served.status === 200, `GET ${route.path} as owner: expected 200, got ${served.status}`);
179
+ await served.text();
180
+ for (const r of REFUSALS) {
181
+ const got = await read(await send(r.who, { method: "GET", path: route.path, headers: good() }));
182
+ check(got.status === r.status, `GET ${route.path} as ${r.who} right after the owner was served (round ${round}): expected ${r.status}, got ${got.status}`);
183
+ check(!DATA_WORDS.test(got.text), `GET ${route.path} as ${r.who} right after the owner was served: the answer holds the game's data`);
184
+ }
185
+ }
186
+ }
187
+ });
188
+ add("(when the host can end a login) a login that the game ended is refused at once, even right after it was used", async () => {
189
+ if (!host.endLogin)
190
+ return;
191
+ for (const route of host.routes.filter((r) => r.method === "GET")) {
192
+ const first = await send("owner", { method: "GET", path: route.path, headers: good() });
193
+ check(first.status === 200, `GET ${route.path} as owner: expected 200, got ${first.status}`);
194
+ await first.text();
195
+ await host.endLogin("owner");
196
+ const after = await read(await send("owner", { method: "GET", path: route.path, headers: good(), again: true }));
197
+ check(after.status === 401, `GET ${route.path} with the same login after the game ended it: expected 401, got ${after.status} (does the game keep sessions in a cache?)`);
198
+ }
199
+ });
200
+ add("(when the admin has Sign out everywhere) the login that used it is refused at once, even though it was used a moment before", async () => {
201
+ const out = host.routes.find((r) => r.method === "POST" && r.path.endsWith("/signout-all"));
202
+ if (!out)
203
+ return;
204
+ const reads = host.routes.find((r) => r.method === "GET");
205
+ check((await send("owner", { method: "GET", path: reads.path, headers: good() })).status === 200, "the owner could not read before signing out");
206
+ const done = await send("owner", { method: "POST", path: out.path, headers: good(), body: "{}" });
207
+ check(done.status === 200, `POST ${out.path} as owner: expected 200, got ${done.status}`);
208
+ const after = await send("owner", { method: "GET", path: reads.path, headers: good(), again: true });
209
+ check(after.status === 401, `GET ${reads.path} with the login that signed out everywhere: expected 401, got ${after.status} (does the game keep sessions in a cache?)`);
210
+ });
211
+ add("a player's login token sent in any other place than the game's cookie (a Bearer header, a query parameter, another header) opens nothing", async () => {
212
+ for (const route of host.routes) {
213
+ for (const tokenAt of ["bearer", "query", "header"]) {
214
+ for (const who of ["game_login", "game_token_as_admin"]) {
215
+ const got = await read(await send(who, { method: route.method, path: route.path, headers: good(), body: body(route), tokenAt }, false));
216
+ const here = `${route.method} ${route.path} as ${who} with the token in ${tokenAt}`;
217
+ check(got.status === 401 || got.status === 403, `${here}: expected 401 or 403, got ${got.status}`);
218
+ check(!DATA_WORDS.test(got.text), `${here}: the answer holds the game's data`);
219
+ }
220
+ }
221
+ }
222
+ });
223
+ add("the owner gets through the guard on every route", async () => {
224
+ for (const route of host.routes) {
225
+ const res = await send("owner", { method: route.method, path: route.path, headers: good(), body: body(route) });
226
+ const got = await read(res);
227
+ check(![401, 403, 404, 405].includes(got.status) && got.status < 500, `${route.method} ${route.path} as owner: the guard let the owner through, but the status was ${got.status}`);
228
+ }
229
+ });
230
+ add("a POST from another site is refused (403), even with the owner's cookie", async () => {
231
+ for (const route of host.routes.filter((r) => r.method === "POST")) {
232
+ const other = await read(await send("owner", { method: "POST", path: route.path, headers: { Origin: "https://evil.example", "Content-Type": "application/json" }, body: "{}" }));
233
+ check(other.status === 403, `POST ${route.path} with another Origin: expected 403, got ${other.status}`);
234
+ const fetchSite = await read(await send("owner", { method: "POST", path: route.path, headers: { "Sec-Fetch-Site": "cross-site", "Content-Type": "application/json" }, body: "{}" }));
235
+ check(fetchSite.status === 403, `POST ${route.path} with no Origin and Sec-Fetch-Site cross-site: expected 403, got ${fetchSite.status}`);
236
+ const none = await read(await send("owner", { method: "POST", path: route.path, headers: { "Content-Type": "application/json" }, body: "{}" }));
237
+ check(none.status === 403, `POST ${route.path} with neither Origin nor Sec-Fetch-Site: expected 403, got ${none.status}`);
238
+ }
239
+ });
240
+ add("a method that a route does not have is refused: the guard's answer for a visitor, 405 for the owner", async () => {
241
+ for (const route of host.routes) {
242
+ const owner = await read(await send("owner", { method: "DELETE", path: route.path, headers: good() }));
243
+ check(owner.status === 405, `DELETE ${route.path} as owner: expected 405, got ${owner.status}`);
244
+ const visitor = await read(await send("signed_out", { method: "DELETE", path: route.path, headers: good() }));
245
+ check(visitor.status === 401, `DELETE ${route.path} signed out: expected 401, got ${visitor.status}`);
246
+ }
247
+ });
248
+ add("a body that is far too big is refused and changes nothing", async () => {
249
+ const big = JSON.stringify({ character: "own", pad: "x".repeat(100_000) });
250
+ for (const route of host.routes.filter((r) => r.method === "POST" && r.path.endsWith("/game"))) {
251
+ const got = await read(await send("owner", { method: "POST", path: route.path, headers: good(), body: big }));
252
+ check(got.status === 413 || got.status === 400, `POST ${route.path} with 100 KB: expected 413 or 400, got ${got.status}`);
253
+ }
254
+ });
255
+ return cases;
256
+ }
257
+ const rawOf = (xno) => (BigInt(Math.round(xno * 10_000)) * 10n ** 26n).toString();
258
+ export function adminStoreConformance(make) {
259
+ const cases = [];
260
+ const add = (name, run) => cases.push({ name, run: async () => run(await make()) });
261
+ const NOW = 1_800_000_000_000;
262
+ const A = `nano_1${"a".repeat(59)}`;
263
+ const B = `nano_3${"b".repeat(59)}`;
264
+ const order = (id, over) => ({ id, player: A, lookId: "mint-cap", usd: "0.25", amountRaw: rawOf(0.25), status: "paid", createdAt: NOW - 1000, expiresAt: NOW + 1000, paidAt: NOW - 500, payer: A, hash: `H${id}`, ...over });
265
+ add("a new store has no settings of its own", async ({ store }) => {
266
+ const s = await store.readSettings();
267
+ check(s.character === undefined && s.signin === undefined, `expected no settings, got ${JSON.stringify(s)}`);
268
+ });
269
+ add("a patch saves only what it names; the next patch keeps the rest", async ({ store }) => {
270
+ await store.saveSettings({ signin: "none" }, NOW);
271
+ let s = await store.readSettings();
272
+ check(s.signin === "none" && s.character === undefined, `after signin none: ${JSON.stringify(s)}`);
273
+ await store.saveSettings({ character: "own" }, NOW + 1);
274
+ s = await store.readSettings();
275
+ check(s.signin === "none" && s.character === "own", `after character own: ${JSON.stringify(s)}`);
276
+ await store.saveSettings({ signin: "exchange", character: "nanoterra" }, NOW + 2);
277
+ s = await store.readSettings();
278
+ check(s.signin === "exchange" && s.character === "nanoterra", `after both: ${JSON.stringify(s)}`);
279
+ });
280
+ add("an empty patch writes nothing and does not fail", async ({ store }) => {
281
+ await store.saveSettings({}, NOW);
282
+ const s = await store.readSettings();
283
+ check(s.character === undefined && s.signin === undefined, "an empty patch must save nothing");
284
+ });
285
+ add("saving changes nothing but the two settings: no order, no owned look, no other meta row", async (h) => {
286
+ await h.seed({ orders: [order("1", {}), order("2", { status: "open" })], owned: [{ player: A, lookId: "mint-cap", at: NOW }], rate: { value: JSON.stringify({ usd: 1.2, sources: ["x"] }), at: NOW } });
287
+ const before = await h.snapshot();
288
+ await h.store.saveSettings({ character: "own", signin: "none" }, NOW + 5);
289
+ check((await h.snapshot()) === before, "the rows of the game changed after saving the settings");
290
+ });
291
+ add("the summary counts the orders by status, reads an open order past its expiry as expired, and totals the money", async ({ store, seed }) => {
292
+ await seed({
293
+ orders: [
294
+ order("a", { status: "paid", usd: "0.25", amountRaw: rawOf(0.25) }),
295
+ order("b", { status: "paid", usd: "0.50", amountRaw: rawOf(0.5) }),
296
+ order("c", { status: "paid_late", usd: "1.10", amountRaw: rawOf(0.9) }),
297
+ order("d", { status: "open", expiresAt: NOW + 1000, paidAt: null, payer: null, hash: null }),
298
+ order("e", { status: "open", expiresAt: NOW - 1000, paidAt: null, payer: null, hash: null }),
299
+ order("f", { status: "expired", expiresAt: NOW - 5000, paidAt: null, payer: null, hash: null }),
300
+ ],
301
+ });
302
+ const s = await store.summary(NOW);
303
+ check(JSON.stringify(s.counts) === JSON.stringify({ open: 1, paid: 2, expired: 2, paid_late: 1 }), `counts: ${JSON.stringify(s.counts)}`);
304
+ check(JSON.stringify(s.totals.paid) === JSON.stringify({ count: 2, xno: "0.75", usd: "0.75" }), `paid: ${JSON.stringify(s.totals.paid)}`);
305
+ check(JSON.stringify(s.totals.paidLate) === JSON.stringify({ count: 1, xno: "0.9", usd: "1.10" }), `paid late: ${JSON.stringify(s.totals.paidLate)}`);
306
+ const e = s.orders.find((o) => o.id === "e");
307
+ check(e?.status === "expired", `an open order past its expiry reads "expired", got ${e?.status}`);
308
+ check(s.orders.find((o) => o.id === "d")?.status === "open", "an open order that has not run out stays open");
309
+ const a = s.orders.find((o) => o.id === "a");
310
+ check(a.xno === "0.25" && a.usd === "0.25" && a.player === A && a.lookId === "mint-cap" && a.hash === "Ha" && a.payer === A && a.paidAt === NOW - 500, `an order's fields: ${JSON.stringify(a)}`);
311
+ });
312
+ add(`the summary lists the newest ${ORDERS_SHOWN} orders, newest first, and still counts them all`, async ({ store, seed }) => {
313
+ const rows = [];
314
+ for (let i = 0; i < ORDERS_SHOWN + 5; i++)
315
+ rows.push(order(`o${String(i).padStart(4, "0")}`, { createdAt: NOW - 100_000 + i, status: "paid", hash: `H${i}` }));
316
+ await seed({ orders: rows });
317
+ const s = await store.summary(NOW);
318
+ check(s.orders.length === ORDERS_SHOWN, `expected ${ORDERS_SHOWN} orders, got ${s.orders.length}`);
319
+ check(s.orders[0].id === `o${String(ORDERS_SHOWN + 4).padStart(4, "0")}`, `the first order must be the newest, got ${s.orders[0].id}`);
320
+ check(s.orders.every((o, i, all) => i === 0 || all[i - 1].createdAt >= o.createdAt), "the orders are not newest first");
321
+ check(s.counts.paid === ORDERS_SHOWN + 5, `the count must be over all orders, got ${s.counts.paid}`);
322
+ check(s.totals.paid.count === ORDERS_SHOWN + 5, `the total must be over all orders, got ${s.totals.paid.count}`);
323
+ });
324
+ add("the summary lists the looks that players own, by player and in the order they were granted", async ({ store, seed }) => {
325
+ await seed({ owned: [{ player: B, lookId: "royal", at: NOW - 10 }, { player: A, lookId: "sunset", at: NOW - 30 }, { player: A, lookId: "mint-cap", at: NOW - 40 }] });
326
+ const s = await store.summary(NOW);
327
+ check(JSON.stringify(s.players) === JSON.stringify([{ player: A, looks: [{ id: "mint-cap", at: NOW - 40 }, { id: "sunset", at: NOW - 30 }] }, { player: B, looks: [{ id: "royal", at: NOW - 10 }] }]), JSON.stringify(s.players));
328
+ });
329
+ add("the rate is null when there is none, read when it is good, and null when the row is junk", async ({ store, seed }) => {
330
+ check((await store.summary(NOW)).rate === null, "no rate row must give null");
331
+ await seed({ rate: { value: JSON.stringify({ usd: 1.18, sources: ["a", "b"] }), at: NOW - 60 } });
332
+ check(JSON.stringify((await store.summary(NOW)).rate) === JSON.stringify({ usd: 1.18, sources: ["a", "b"], at: NOW - 60 }), "a good rate row must be read");
333
+ await seed({ rate: { value: "not json", at: NOW } });
334
+ check((await store.summary(NOW)).rate === null, "a junk rate row must give null");
335
+ });
336
+ // The sign-in list and "sign out everywhere" are optional: a store that has them must pass these cases (a store that has not skips them).
337
+ add("(when the store lists sign-ins) the last sign-ins come newest first, at most the limit, with only the time, the country and the browser", async (h) => {
338
+ if (!h.store.recentLogins)
339
+ return;
340
+ check(Boolean(h.seedLogins), "a store with recentLogins needs seedLogins in its harness");
341
+ check((await h.store.recentLogins(10)).length === 0, "a new store lists no sign-ins");
342
+ const rows = Array.from({ length: 12 }, (_, i) => ({ at: NOW - 1000 * (12 - i), country: i % 3 === 0 ? null : "US", browser: `Chrome on macOS ${i}` }));
343
+ await h.seedLogins(rows);
344
+ const got = await h.store.recentLogins(10);
345
+ check(got.length === 10, `expected 10 sign-ins, got ${got.length}`);
346
+ check(got.every((l, i, all) => i === 0 || all[i - 1].at >= l.at), "the sign-ins are not newest first");
347
+ check(got[0].at === NOW - 1000 && got[0].browser === "Chrome on macOS 11", `the first must be the newest: ${JSON.stringify(got[0])}`);
348
+ check(got.every((l) => Object.keys(l).sort().join() === "at,browser,country"), `a sign-in must carry only at, browser and country: ${JSON.stringify(got[0])}`);
349
+ check((await h.store.recentLogins(3)).length === 3, "the limit must hold");
350
+ check((await h.store.recentLogins(0)).length === 0, "a limit of 0 lists none");
351
+ });
352
+ add("(when the store can sign out everywhere) it deletes every admin session and no player session, says how many, and changes nothing else", async (h) => {
353
+ if (!h.store.signOutEverywhere)
354
+ return;
355
+ check(Boolean(h.seedSessions && h.sessionCounts), "a store with signOutEverywhere needs seedSessions and sessionCounts in its harness");
356
+ check(Boolean(h.seedRequests && h.pendingRequests), "a store with signOutEverywhere needs seedRequests and pendingRequests in its harness: the contract must prove that it ends the admin sign-in requests that were not delivered");
357
+ await h.seed({ orders: [order("1", {}), order("2", { status: "open" })], owned: [{ player: A, lookId: "mint-cap", at: NOW }] });
358
+ await h.seedSessions({ admin: 3, play: 4 });
359
+ await h.seedRequests({ admin: 2, game: 3 });
360
+ const before = await h.snapshot();
361
+ const n = await h.store.signOutEverywhere();
362
+ check(n === 3, `expected 3 admin sessions deleted, got ${n}`);
363
+ const left = await h.sessionCounts();
364
+ check(left.admin === 0 && left.play === 4, `expected 0 admin and 4 player sessions, got ${JSON.stringify(left)}`);
365
+ check((await h.snapshot()) === before, "the orders, the owned looks or the meta rows changed");
366
+ check((await h.store.signOutEverywhere()) === 0, "a second call deletes nothing and says 0");
367
+ check((await h.sessionCounts()).play === 4, "the player sessions are still there");
368
+ const p = await h.pendingRequests();
369
+ check(p.admin === 0, `an admin sign-in request that was started and not delivered must be ended too, ${p.admin} are left (a request signed a moment ago could make a new login)`);
370
+ check(p.game === 3, `the game's own sign-in requests must be left alone, ${p.game} of 3 are left`);
371
+ });
372
+ return cases;
373
+ }
374
+ export function adminSigninConformance(host, options = {}) {
375
+ const cases = [];
376
+ const add = (name, run) => cases.push({ name, run });
377
+ const SCOPES = ["game", "admin"];
378
+ const other = (s) => (s === "admin" ? "game" : "admin");
379
+ const none = (got) => !got.admin && !got.game;
380
+ const owns = (got, scope) => (scope === "admin" ? got.admin && !got.game : got.game && !got.admin);
381
+ /** A refused attempt must leave the request as it was: its own browser still signs in, in its own scope, once. This also proves that the signature landed. */
382
+ async function stillSignsIn(req, scope, after) {
383
+ const got = await host.finish(req, scope, "own");
384
+ check(owns(got, scope), `after ${after}, a ${scope} request that was signed no longer signed in its own browser in its own scope: ${JSON.stringify(got)} (a refusal must leave the request as it was; or the signature never landed)`);
385
+ }
386
+ add("control: a request signed in its own scope signs in the browser that started it, in that scope only, once", async () => {
387
+ for (const scope of SCOPES) {
388
+ const req = await host.start(scope);
389
+ await host.sign(req);
390
+ const got = await host.finish(req, scope, "own");
391
+ check(owns(got, scope), `a ${scope} request finished as ${scope} by its own browser: ${JSON.stringify(got)}`);
392
+ const again = await host.finish(req, scope, "own");
393
+ check(none(again), `a ${scope} request must sign in once: the second check gave ${JSON.stringify(again)}`);
394
+ const stranger = await host.finish(req, scope, "other");
395
+ check(none(stranger), `a ${scope} request that was already collected must give another browser no login either: ${JSON.stringify(stranger)}`);
396
+ }
397
+ });
398
+ add("a request signs in only the browser that started it: another browser that knows the request signs in nobody, in either scope", async () => {
399
+ for (const scope of SCOPES) {
400
+ const req = await host.start(scope);
401
+ await host.sign(req);
402
+ const got = await host.finish(req, scope, "other");
403
+ check(none(got), `a ${scope} request finished in its own scope by ANOTHER browser made a login: ${JSON.stringify(got)}`);
404
+ await stillSignsIn(req, scope, "another browser asked in the same scope");
405
+ }
406
+ });
407
+ add("a request never makes a login in the OTHER scope: not for the browser that started it, not for another browser, not for retyped credentials; and no login of any kind is made", async () => {
408
+ for (const scope of SCOPES) {
409
+ for (const credentials of ["own", "other", "retyped"]) {
410
+ const req = await host.start(scope);
411
+ await host.sign(req);
412
+ const got = await host.finish(req, other(scope), credentials);
413
+ check(none(got), `a ${scope} request finished as the ${other(scope)} with ${credentials} credentials made a login: ${JSON.stringify(got)}`);
414
+ await stillSignsIn(req, scope, `an ask as the ${other(scope)} with ${credentials} credentials`);
415
+ }
416
+ }
417
+ });
418
+ add("(when the admin has Sign out everywhere) a signed admin request makes no login after it, and neither does a waiting one that is signed afterwards; a game request is left alone", async () => {
419
+ if (!host.signOutEverywhere) {
420
+ check(!options.signOutEverywhere, "the host has no signOutEverywhere, but the contract was told that the admin has Sign out everywhere");
421
+ return;
422
+ }
423
+ // signed, then signed out everywhere
424
+ const signed = await host.start("admin");
425
+ await host.sign(signed);
426
+ // waiting when signed out everywhere, signed afterwards
427
+ const waiting = await host.start("admin");
428
+ // a game request, signed, left alone
429
+ const game = await host.start("game");
430
+ await host.sign(game);
431
+ await host.signOutEverywhere();
432
+ check(none(await host.finish(signed, "admin", "own")), "a signed admin request that was not yet delivered made a login after Sign out everywhere");
433
+ try {
434
+ await host.sign(waiting); // the app may refuse it (the request ended): that is fine
435
+ }
436
+ catch {
437
+ // refused
438
+ }
439
+ check(none(await host.finish(waiting, "admin", "own")), "an admin request that was waiting at Sign out everywhere made a login when it was signed afterwards");
440
+ check(owns(await host.finish(game, "game", "own"), "game"), "Sign out everywhere must leave the game's own sign-in requests alone");
441
+ });
442
+ add("(when the host can move its clock) the cross-scope asks are refused after the admin's request life has ended too, inside the game's hour of delivery", async () => {
443
+ if (!host.advance) {
444
+ check(!options.advance, "the host has no advance, but the contract was told that the game's clock can be moved");
445
+ return;
446
+ }
447
+ const made = [];
448
+ for (const scope of SCOPES) {
449
+ const req = await host.start(scope);
450
+ await host.sign(req);
451
+ made.push({ req, scope });
452
+ }
453
+ // Ten minutes after the start (the admin's life and its minute of delivery are over, the game's five minutes too), and again at 55 minutes (a game request is still delivered inside its hour).
454
+ let waited = 0;
455
+ for (const minutes of [10, 55]) {
456
+ await host.advance((minutes - waited) * 60_000);
457
+ waited = minutes;
458
+ for (const { req, scope } of made) {
459
+ for (const credentials of ["own", "other", "retyped"]) {
460
+ const got = await host.finish(req, other(scope), credentials);
461
+ check(none(got), `${minutes} minutes later, a ${scope} request finished as the ${other(scope)} with ${credentials} credentials made a login: ${JSON.stringify(got)}`);
462
+ }
463
+ }
464
+ }
465
+ });
466
+ return cases;
467
+ }
@@ -0,0 +1,38 @@
1
+ /** 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. */
2
+ export type AdminSession = {
3
+ address: string;
4
+ createdAt: number;
5
+ /** "wallet" is a signature made with the address's key. Anything else (a sign-in from Nanoterra names an address but proves no key) never opens /admin. */
6
+ via: string;
7
+ };
8
+ export type AdminAccessInput = {
9
+ /** The game's own address, in the form "nano_" and 60 characters, lowercase. A host passes null when none is set (or the example address is). */
10
+ owner: string | null | undefined;
11
+ /** The admin login of this request, or null when there is none. It must be the ADMIN login: a login made for the game's players is not one. */
12
+ session: AdminSession | null | undefined;
13
+ /** The time now, in ms. */
14
+ now: number;
15
+ /** How old an admin login may be, in ms. At most one day: a bigger number is cut to one day, never allowed. */
16
+ maxAgeMs?: number;
17
+ };
18
+ /** Why a visitor was refused: the status to send and a word a page can act on. */
19
+ export type AdminRefusal = {
20
+ ok: false;
21
+ status: 401 | 403;
22
+ error: string;
23
+ /** "no_address": the game has no real owner address yet. "nanoterra": a Nanoterra sign-in names an address but proves no key. "other_address": a wallet that is not the owner's. "misconfigured": the host passed a value the guard cannot trust. */
24
+ reason?: "no_address" | "nanoterra" | "other_address" | "misconfigured";
25
+ /** For "other_address": the address that is signed in (the page says "you are signed in as ..."). */
26
+ signedInAs?: string;
27
+ };
28
+ export type AdminAccess = {
29
+ ok: true;
30
+ } | AdminRefusal;
31
+ /** An admin login counts for one day; then the owner signs in again. */
32
+ export declare const ADMIN_SESSION_MS: number;
33
+ /** A login made in the future by this much or more is not believed (clocks of two servers differ by seconds, not by minutes). */
34
+ export declare const CLOCK_SKEW_MS: number;
35
+ /** A Nano address as a page writes it: "nano_", then 60 characters of the Nano alphabet, the first one a 1 or a 3. The guard checks the shape; the host checks the checksum when it reads its setting. */
36
+ export declare const OWNER_PATTERN: RegExp;
37
+ /** `ok: true` only when the request may read and change the admin data. */
38
+ export declare function adminAccess(input: AdminAccessInput): AdminAccess;
@@ -0,0 +1,57 @@
1
+ // The admin guard: who may open a game's /admin. ONE pure function: no cookie, no database, no clock of its own. The game reads its own admin
2
+ // session and passes it in; the answer is "yes" or a refusal with a status and a reason.
3
+ //
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
+ // 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
+ // does not understand is a refusal, and a fault inside it is a refusal too.
7
+ /** An admin login counts for one day; then the owner signs in again. */
8
+ export const ADMIN_SESSION_MS = 24 * 60 * 60_000;
9
+ /** A login made in the future by this much or more is not believed (clocks of two servers differ by seconds, not by minutes). */
10
+ export const CLOCK_SKEW_MS = 5 * 60_000;
11
+ /** A Nano address as a page writes it: "nano_", then 60 characters of the Nano alphabet, the first one a 1 or a 3. The guard checks the shape; the host checks the checksum when it reads its setting. */
12
+ export const OWNER_PATTERN = /^nano_[13][13-9a-km-uw-z]{59}$/;
13
+ const refuse = (status, error, extra = {}) => ({ ok: false, status, error, ...extra });
14
+ const misconfigured = () => refuse(403, "The admin page is not set up correctly. Nobody can open it until it is.", { reason: "misconfigured" });
15
+ /** `ok: true` only when the request may read and change the admin data. */
16
+ export function adminAccess(input) {
17
+ try {
18
+ return decide(input);
19
+ }
20
+ catch {
21
+ // A getter that throws, a Proxy, anything odd: no.
22
+ return misconfigured();
23
+ }
24
+ }
25
+ function decide(input) {
26
+ const { owner, session, now, maxAgeMs } = input;
27
+ if (typeof now !== "number" || !Number.isFinite(now))
28
+ return misconfigured();
29
+ let max = ADMIN_SESSION_MS;
30
+ if (maxAgeMs !== undefined) {
31
+ // A limit the host set must be a real, positive number. A wrong one is a refusal, not the default: the host meant something shorter.
32
+ if (typeof maxAgeMs !== "number" || !Number.isFinite(maxAgeMs) || maxAgeMs <= 0)
33
+ return misconfigured();
34
+ max = Math.min(maxAgeMs, ADMIN_SESSION_MS);
35
+ }
36
+ // 1. The game has a real owner address.
37
+ if (typeof owner !== "string" || !OWNER_PATTERN.test(owner)) {
38
+ return refuse(403, "Set the game's own Nano address first. Only that address can open this page.", { reason: "no_address" });
39
+ }
40
+ // 2. There is an admin login.
41
+ if (!session || typeof session !== "object")
42
+ return refuse(401, "Sign in with your Nano address.");
43
+ const { address, createdAt, via } = session;
44
+ if (typeof address !== "string" || typeof via !== "string" || typeof createdAt !== "number")
45
+ return refuse(401, "Sign in with your Nano address.");
46
+ // 3. It was made with a wallet signature. A sign-in from Nanoterra names an address but proves no key.
47
+ if (via !== "wallet")
48
+ return refuse(403, "Sign in with your Nano wallet to open this page.", { reason: "nanoterra" });
49
+ // 4. It is the owner's address (the same text, no other form).
50
+ if (address !== owner)
51
+ return refuse(403, "Only the game's own address can open this page.", { reason: "other_address", signedInAs: address });
52
+ // 5. It is not older than the limit (one day at most), and not from the future.
53
+ const age = now - createdAt;
54
+ if (!(age >= -CLOCK_SKEW_MS && age <= max))
55
+ return refuse(401, "Sign in again: an admin sign-in counts for one day.");
56
+ return { ok: true };
57
+ }
@@ -0,0 +1,63 @@
1
+ import { type AdminSession } from "./guard.js";
2
+ import { type AdminStatus, type AdminStore, type StoreSummary } from "./store.js";
3
+ import { type GameSettings, type GameState } from "./switches.js";
4
+ export type AdminRoute = {
5
+ method: "GET" | "POST";
6
+ path: string;
7
+ };
8
+ export type AdminHandlerOptions = {
9
+ /** The game's database. */
10
+ store: AdminStore;
11
+ /** What a switch is while /admin has not set it: the game's own words (game.config.json in the starter). */
12
+ defaults: GameSettings;
13
+ /** The game's own address ("nano_...", lowercase, its checksum already checked by the host), or null when none is set. */
14
+ owner: string | null;
15
+ /** The admin login of this request, or null. It reads the ADMIN cookie only (a login made for the game's players is not one). */
16
+ session: (req: Request, now: number) => Promise<AdminSession | null>;
17
+ /** The Status card: what only the game knows. It gets the numbers of the store too, for a warning that depends on them ("2 payments came late"). Default: the owner's address and nothing else. */
18
+ status?: (now: number, summary: StoreSummary) => Promise<AdminStatus> | AdminStatus;
19
+ /** What a look is called, for the orders. Default: its id. */
20
+ lookName?: (id: string) => string | undefined;
21
+ /** The payment check (POST check). Its answer is sent as `scan`. With none given, the route does not exist. */
22
+ check?: (now: number) => Promise<unknown>;
23
+ /** Where the routes are. Default "/api/admin". */
24
+ basePath?: string;
25
+ /** How old an admin login may be, in ms: one day at most (a bigger number is cut to one day). */
26
+ maxAgeMs?: number;
27
+ /** A limiter for the writes: false means "too many". Default: a fixed window in memory, one bucket for the whole admin (only the owner gets this far). */
28
+ limit?: (key: string, limit: number, windowMs: number, now: number) => boolean | Promise<boolean>;
29
+ /** The time now in ms. Default Date.now. */
30
+ now?: () => number;
31
+ /** Told about a fault in the store or in a callback. The visitor gets a plain 500 and never sees the fault. */
32
+ onError?: (e: unknown) => void;
33
+ };
34
+ export type AdminHandler = {
35
+ /** Every route, so that a test can walk them (see "@nano133/nanoterra/admin/conformance"). */
36
+ routes: readonly AdminRoute[];
37
+ basePath: string;
38
+ /** The answer for a request under the base path, or null when the request is not for the admin (the game goes on with its own routes). */
39
+ handle(req: Request): Promise<Response | null>;
40
+ };
41
+ /** The limits of the writes. */
42
+ export declare const ADMIN_LIMITS: {
43
+ /** A press on a switch. */
44
+ readonly game: {
45
+ readonly limit: 30;
46
+ readonly windowMs: 60000;
47
+ };
48
+ /** A payment check. */
49
+ readonly check: {
50
+ readonly limit: 20;
51
+ readonly windowMs: 60000;
52
+ };
53
+ /** "Sign out everywhere". */
54
+ readonly signOutAll: {
55
+ readonly limit: 5;
56
+ readonly windowMs: 60000;
57
+ };
58
+ /** The biggest body a press can have. */
59
+ readonly gameBodyMax: 512;
60
+ };
61
+ export declare function createAdminHandler(options: AdminHandlerOptions): AdminHandler;
62
+ /** The settings as the game's own routes need them (the listing file, the character): the stored words, or the game's defaults. A fault in the store gives the defaults. */
63
+ export declare function loadGameSettings(store: Pick<AdminStore, "readSettings">, defaults: GameSettings): Promise<GameState>;