@nano133/nanoterra 0.1.0 → 0.2.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 (77) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +189 -2
  3. package/ai/SPEC.md +2 -1
  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 +17 -0
  33. package/dist/admin/panel/shelf.js +247 -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/looks/change.d.ts +8 -4
  45. package/dist/looks/change.js +25 -3
  46. package/dist/looks/index.d.ts +3 -3
  47. package/dist/looks/index.js +2 -2
  48. package/dist/looks/json.d.ts +2 -2
  49. package/dist/looks/json.js +6 -4
  50. package/dist/looks/look.d.ts +29 -3
  51. package/dist/looks/look.js +60 -4
  52. package/package.json +9 -2
  53. package/src/admin/adapters/d1.ts +94 -0
  54. package/src/admin/adapters/firestore.ts +78 -0
  55. package/src/admin/amount.ts +14 -0
  56. package/src/admin/conformance.ts +605 -0
  57. package/src/admin/guard.ts +86 -0
  58. package/src/admin/handler.ts +200 -0
  59. package/src/admin/http.ts +74 -0
  60. package/src/admin/index.ts +21 -0
  61. package/src/admin/memory.ts +59 -0
  62. package/src/admin/panel/api.ts +37 -0
  63. package/src/admin/panel/dom.ts +31 -0
  64. package/src/admin/panel/gameCard.ts +63 -0
  65. package/src/admin/panel/index.ts +18 -0
  66. package/src/admin/panel/mount.ts +227 -0
  67. package/src/admin/panel/shelf.ts +337 -0
  68. package/src/admin/panel/shelfData.ts +87 -0
  69. package/src/admin/panel/signins.ts +68 -0
  70. package/src/admin/panel/style.css +542 -0
  71. package/src/admin/store.ts +98 -0
  72. package/src/admin/summary.ts +103 -0
  73. package/src/admin/switches.ts +74 -0
  74. package/src/looks/change.ts +25 -6
  75. package/src/looks/index.ts +3 -3
  76. package/src/looks/json.ts +6 -4
  77. package/src/looks/look.ts +66 -5
@@ -0,0 +1,605 @@
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
+
11
+ import type { AdminRoute } from "./handler.js";
12
+ import type { AdminStore } from "./store.js";
13
+ import { ORDERS_SHOWN, type OrderRow } from "./summary.js";
14
+
15
+ /**
16
+ * The kinds of visitor the game must be able to make:
17
+ * - signed_out no cookie at all
18
+ * - game_login a valid login made for the game's PLAYERS (the owner's own address, even), in the game's own cookie: it is not an admin login
19
+ * - game_token_as_admin the token of that same player login, put in the ADMIN cookie. The game must keep the two scopes apart in its store, not only in the cookie names
20
+ * - nanoterra_login an admin-scope login whose `via` is "nanoterra" (a sign-in from Nanoterra), for the owner's address
21
+ * - unknown_via_login an admin-scope login of the owner's whose `via` is a word the game does not know (like "ticket", or an empty one): it must fail closed
22
+ * - other_wallet a wallet login for an address that is not the owner's
23
+ * - no_address a wallet login while the game has NO owner address set (the game runs with none)
24
+ * - expired_login a wallet login of the owner's, made LESS than a day ago, that the game's own store has already expired (its own life is shorter than a day, or it ended).
25
+ * The guard's age check cannot refuse it, so only the game's store does: a game whose session code ignores its own expiry fails here
26
+ * - signed_out_login a wallet login of the owner's, made just now, that the game's own sign-out route has ended. A game that still reads it fails here
27
+ * - old_valid_row a wallet login of the owner's made more than one day ago whose stored row is STILL valid, so only the guard's own age check can refuse it. The suite sends it
28
+ * TWICE (the second time with `again: true`, the same login): a game that renews the creation time on every read fails here
29
+ * - owner a wallet login of the owner's, made just now
30
+ */
31
+ export type Who = "signed_out" | "game_login" | "game_token_as_admin" | "nanoterra_login" | "unknown_via_login" | "other_wallet" | "no_address" | "expired_login" | "signed_out_login" | "old_valid_row" | "owner";
32
+
33
+ export type ConformanceRequest = {
34
+ method: string;
35
+ path: string;
36
+ headers?: Record<string, string>;
37
+ body?: string;
38
+ /** True when the suite wants the SAME login as the last request of this kind (do not make a new one): a host must support it, or a session that renews itself on each read is not caught. */
39
+ again?: boolean;
40
+ /**
41
+ * Set when the suite wants the login token of this kind sent in a place that is NOT the game's cookie, with NO cookie: "bearer" (`Authorization: Bearer <token>`), "query" (the token in the
42
+ * query as `token`, `t` and `access_token`) or "header" (`X-Auth-Token: <token>`). A host must support it, or a game that also reads a player's token from one of these places is not caught.
43
+ */
44
+ tokenAt?: "bearer" | "query" | "header";
45
+ };
46
+
47
+ export type ConformanceHost = {
48
+ /** The handler's `routes`. */
49
+ routes: readonly AdminRoute[];
50
+ /** The handler's `basePath`. */
51
+ basePath: string;
52
+ /** The game's own origin, like "https://game.example": the suite sends it as the Origin of a good POST. */
53
+ origin: string;
54
+ /**
55
+ * Other routes that the game's OWN router serves under the base path (an old route, a debug route, a route kept for an old page): `{ method, path }`. The suite sends each
56
+ * as every kind of visitor who is not the owner and requires a refusal: a route that the game's router answers BEFORE the handler, with no guard, must show up here.
57
+ * List every one you know of; the suite cannot find the ones your router hides from it.
58
+ */
59
+ otherRoutes?: readonly { method: string; path: string }[];
60
+ request(who: Who, req: ConformanceRequest): Promise<Response>;
61
+ /** Optional but recommended. Ends the login of the last request of this kind with the game's OWN sign-out (the real route): the suite then uses the same login again and requires a refusal. */
62
+ endLogin?(who: Who): Promise<void>;
63
+ };
64
+
65
+ export type ConformanceCase = { name: string; run: () => Promise<void> };
66
+
67
+ const REFUSALS: { who: Who; status: number; reason?: string; words: string }[] = [
68
+ { who: "signed_out", status: 401, words: "a visitor who is signed out" },
69
+ { who: "game_login", status: 401, words: "a login that was made for the game's players" },
70
+ { who: "game_token_as_admin", status: 401, words: "the token of a player's login put in the admin cookie" },
71
+ { who: "nanoterra_login", status: 403, reason: "nanoterra", words: "a Nanoterra sign-in (it names an address but proves no key)" },
72
+ { who: "unknown_via_login", status: 403, words: "an admin login that was made some way the game does not know (it must fail closed)" },
73
+ { who: "other_wallet", status: 403, reason: "other_address", words: "a wallet that is not the owner's" },
74
+ { who: "no_address", status: 403, reason: "no_address", words: "anyone, while the game has no owner address" },
75
+ { who: "expired_login", status: 401, words: "an admin login that the game's own store has expired (made less than a day ago)" },
76
+ { who: "signed_out_login", status: 401, words: "an admin login that the game's own sign-out has ended" },
77
+ { 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)" },
78
+ ];
79
+
80
+ function check(ok: boolean, message: string): void {
81
+ if (!ok) throw new Error(message);
82
+ }
83
+
84
+ async function read(res: Response): Promise<{ status: number; text: string; json: Record<string, unknown> | null }> {
85
+ const text = await res.text();
86
+ let json: Record<string, unknown> | null = null;
87
+ try {
88
+ const v: unknown = JSON.parse(text);
89
+ if (v && typeof v === "object" && !Array.isArray(v)) json = v as Record<string, unknown>;
90
+ } catch {
91
+ // not JSON
92
+ }
93
+ return { status: res.status, text, json };
94
+ }
95
+
96
+ /** A refusal carries words and a reason and nothing else: no data of the game. */
97
+ const REFUSAL_KEYS = new Set(["error", "reason", "signedInAs"]);
98
+
99
+ /** Words of the game's data that a refusal or an answer to a visitor must never hold. */
100
+ const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"warnings"/;
101
+
102
+ export function adminConformance(host: ConformanceHost): ConformanceCase[] {
103
+ const cases: ConformanceCase[] = [];
104
+ const add = (name: string, run: () => Promise<void>) => cases.push({ name, run });
105
+ const good = (extra: Record<string, string> = {}) => ({ Origin: host.origin, "Content-Type": "application/json", ...extra });
106
+ const body = (r: { method: string }) => (r.method === "POST" ? "{}" : undefined);
107
+ const NOT_OWNER = REFUSALS.map((r) => r.who);
108
+
109
+ check(host.routes.length > 0, "the host lists no routes");
110
+
111
+ /**
112
+ * 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
113
+ * 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).
114
+ */
115
+ async function send(who: Who, req: ConformanceRequest, strict = true): Promise<Response> {
116
+ const res = await host.request(who, req);
117
+ let cors = "";
118
+ res.headers.forEach((v, k) => {
119
+ if (k.toLowerCase().startsWith("access-control-")) cors = `${k}: ${v}`;
120
+ });
121
+ check(cors === "", `${req.method} ${req.path} as ${who}: the answer has a CORS header (${cors})`);
122
+ if (strict) 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"`);
123
+ return res;
124
+ }
125
+
126
+ for (const r of REFUSALS) {
127
+ add(`every route refuses ${r.words} (${r.status}${r.reason ? `, ${r.reason}` : ""}) and gives it no data`, async () => {
128
+ for (const route of host.routes) {
129
+ const got = await read(await send(r.who, { method: route.method, path: route.path, headers: good(), body: body(route) }));
130
+ const here = `${route.method} ${route.path} as ${r.who}`;
131
+ check(got.status === r.status, `${here}: expected ${r.status}, got ${got.status}`);
132
+ if (r.reason) check(got.json?.reason === r.reason, `${here}: expected reason "${r.reason}", got ${JSON.stringify(got.json?.reason)}`);
133
+ 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)}`);
134
+ }
135
+ });
136
+ }
137
+
138
+ 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 () => {
139
+ for (const r of REFUSALS) {
140
+ for (const route of host.routes) {
141
+ for (const method of ["OPTIONS", "HEAD"]) {
142
+ const headers = method === "OPTIONS" ? good({ "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "content-type" }) : good();
143
+ const got = await send(r.who, { method, path: route.path, headers });
144
+ check(got.status === r.status, `${method} ${route.path} as ${r.who}: expected ${r.status}, got ${got.status}`);
145
+ }
146
+ }
147
+ }
148
+ });
149
+
150
+ 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 () => {
151
+ const path = `${host.basePath}/no-such-page`;
152
+ for (const r of REFUSALS) {
153
+ const got = await send(r.who, { method: "GET", path });
154
+ check(got.status === r.status, `GET ${path} as ${r.who}: expected ${r.status}, got ${got.status}`);
155
+ }
156
+ const owner = await send("owner", { method: "GET", path });
157
+ check(owner.status === 404, `GET ${path} as owner: expected 404, got ${owner.status}`);
158
+ });
159
+
160
+ 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 () => {
161
+ for (const route of host.otherRoutes ?? []) {
162
+ for (const who of NOT_OWNER) {
163
+ const got = await read(await send(who, { method: route.method, path: route.path, headers: good(), body: body(route) }, false));
164
+ const here = `${route.method} ${route.path} as ${who}`;
165
+ check(got.status === 401 || got.status === 403, `${here}: expected 401 or 403, got ${got.status}`);
166
+ check(!DATA_WORDS.test(got.text), `${here}: the answer holds the game's data`);
167
+ 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)}`);
168
+ }
169
+ }
170
+ });
171
+
172
+ /** Other spellings of one admin path: other case, a slash, a doubled slash, a percent code, a backslash, a dot segment. */
173
+ const spellings = (path: string): string[] => {
174
+ const last = path.slice(path.lastIndexOf("/") + 1);
175
+ const base = host.basePath;
176
+ return [
177
+ path.toUpperCase(),
178
+ `${path}/`,
179
+ path.replace(`/${last}`, `//${last}`),
180
+ path.slice(0, -1) + `%${path.charCodeAt(path.length - 1).toString(16)}`,
181
+ path.replace(`/${last}`, `/x/../${last}`),
182
+ `${base}%2f${last}`,
183
+ `${base}\\${last}`,
184
+ `${base}%5c${last}`,
185
+ `${base.replace(/^\/(\w+)\//, "/$1%2f")}/${last}`,
186
+ `${base.replace(/^\/(\w)/, (_, c: string) => `/%${c.charCodeAt(0).toString(16)}`)}/${last}`,
187
+ ];
188
+ };
189
+
190
+ add("other spellings of EVERY admin path never open anything for a visitor, with any method", async () => {
191
+ const seen = new Set<string>();
192
+ for (const route of host.routes) {
193
+ for (const path of spellings(route.path)) {
194
+ if (seen.has(path)) continue;
195
+ seen.add(path);
196
+ for (const who of NOT_OWNER) {
197
+ for (const method of new Set([route.method, "GET", "POST", "OPTIONS", "HEAD"])) {
198
+ const got = await read(await send(who, { method, path, headers: good(), body: body({ method }) }, false));
199
+ check([401, 403, 404, 405].includes(got.status), `${method} ${path} as ${who}: expected 401, 403, 404 or 405, got ${got.status}`);
200
+ check(!DATA_WORDS.test(got.text), `${method} ${path} as ${who}: the answer holds the game's data`);
201
+ }
202
+ }
203
+ }
204
+ }
205
+ });
206
+
207
+ 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 () => {
208
+ const own = new URL(host.origin);
209
+ const origins = ["https://evil.example", "null", `${own.protocol}//evil.${own.host}`, `${own.protocol}//${own.host.replace(/^[^.]+\./, "sibling.")}`, `${own.protocol}//${own.host}.evil.example`];
210
+ const WHO: Who[] = ["owner", ...NOT_OWNER];
211
+ for (const route of host.routes) {
212
+ for (const origin of origins) {
213
+ for (const who of WHO) {
214
+ for (const method of new Set([route.method, "GET", "OPTIONS", "HEAD"])) {
215
+ const got = await read(await send(who, { method, path: route.path, headers: { Origin: origin, "Content-Type": "application/json" }, body: body({ method }) }));
216
+ const here = `${method} ${route.path} as ${who} with Origin ${origin}`;
217
+ check([401, 403, 405].includes(got.status), `${here}: a foreign page must get 401, 403 or 405, got ${got.status}`);
218
+ check(!DATA_WORDS.test(got.text), `${here}: the answer holds the game's data`);
219
+ }
220
+ }
221
+ }
222
+ // 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.
223
+ for (const site of ["cross-site", "same-site"]) {
224
+ 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) }));
225
+ check(got.status === 403, `${route.method} ${route.path} as owner with Sec-Fetch-Site ${site}: expected 403, got ${got.status}`);
226
+ }
227
+ }
228
+ });
229
+
230
+ 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 () => {
231
+ for (const route of host.routes) {
232
+ for (const again of [false, true, true]) {
233
+ const got = await read(await send("old_valid_row", { method: route.method, path: route.path, headers: good(), body: body(route), again }));
234
+ check(got.status === 401, `${route.method} ${route.path} as old_valid_row${again ? " (the same login again)" : ""}: expected 401, got ${got.status}`);
235
+ }
236
+ }
237
+ });
238
+
239
+ 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 () => {
240
+ for (const route of host.routes.filter((r) => r.method === "GET")) {
241
+ for (const round of [1, 2]) {
242
+ const served = await send("owner", { method: "GET", path: route.path, headers: good() });
243
+ check(served.status === 200, `GET ${route.path} as owner: expected 200, got ${served.status}`);
244
+ await served.text();
245
+ for (const r of REFUSALS) {
246
+ const got = await read(await send(r.who, { method: "GET", path: route.path, headers: good() }));
247
+ 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}`);
248
+ 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`);
249
+ }
250
+ }
251
+ }
252
+ });
253
+
254
+ 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 () => {
255
+ if (!host.endLogin) return;
256
+ for (const route of host.routes.filter((r) => r.method === "GET")) {
257
+ const first = await send("owner", { method: "GET", path: route.path, headers: good() });
258
+ check(first.status === 200, `GET ${route.path} as owner: expected 200, got ${first.status}`);
259
+ await first.text();
260
+ await host.endLogin("owner");
261
+ const after = await read(await send("owner", { method: "GET", path: route.path, headers: good(), again: true }));
262
+ 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?)`);
263
+ }
264
+ });
265
+
266
+ 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 () => {
267
+ const out = host.routes.find((r) => r.method === "POST" && r.path.endsWith("/signout-all"));
268
+ if (!out) return;
269
+ const reads = host.routes.find((r) => r.method === "GET")!;
270
+ check((await send("owner", { method: "GET", path: reads.path, headers: good() })).status === 200, "the owner could not read before signing out");
271
+ const done = await send("owner", { method: "POST", path: out.path, headers: good(), body: "{}" });
272
+ check(done.status === 200, `POST ${out.path} as owner: expected 200, got ${done.status}`);
273
+ const after = await send("owner", { method: "GET", path: reads.path, headers: good(), again: true });
274
+ 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?)`);
275
+ });
276
+
277
+ 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 () => {
278
+ for (const route of host.routes) {
279
+ for (const tokenAt of ["bearer", "query", "header"] as const) {
280
+ for (const who of ["game_login", "game_token_as_admin"] as const) {
281
+ const got = await read(await send(who, { method: route.method, path: route.path, headers: good(), body: body(route), tokenAt }, false));
282
+ const here = `${route.method} ${route.path} as ${who} with the token in ${tokenAt}`;
283
+ check(got.status === 401 || got.status === 403, `${here}: expected 401 or 403, got ${got.status}`);
284
+ check(!DATA_WORDS.test(got.text), `${here}: the answer holds the game's data`);
285
+ }
286
+ }
287
+ }
288
+ });
289
+
290
+ add("the owner gets through the guard on every route", async () => {
291
+ for (const route of host.routes) {
292
+ const res = await send("owner", { method: route.method, path: route.path, headers: good(), body: body(route) });
293
+ const got = await read(res);
294
+ 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}`);
295
+ }
296
+ });
297
+
298
+ add("a POST from another site is refused (403), even with the owner's cookie", async () => {
299
+ for (const route of host.routes.filter((r) => r.method === "POST")) {
300
+ const other = await read(await send("owner", { method: "POST", path: route.path, headers: { Origin: "https://evil.example", "Content-Type": "application/json" }, body: "{}" }));
301
+ check(other.status === 403, `POST ${route.path} with another Origin: expected 403, got ${other.status}`);
302
+ const fetchSite = await read(await send("owner", { method: "POST", path: route.path, headers: { "Sec-Fetch-Site": "cross-site", "Content-Type": "application/json" }, body: "{}" }));
303
+ check(fetchSite.status === 403, `POST ${route.path} with no Origin and Sec-Fetch-Site cross-site: expected 403, got ${fetchSite.status}`);
304
+ const none = await read(await send("owner", { method: "POST", path: route.path, headers: { "Content-Type": "application/json" }, body: "{}" }));
305
+ check(none.status === 403, `POST ${route.path} with neither Origin nor Sec-Fetch-Site: expected 403, got ${none.status}`);
306
+ }
307
+ });
308
+
309
+ add("a method that a route does not have is refused: the guard's answer for a visitor, 405 for the owner", async () => {
310
+ for (const route of host.routes) {
311
+ const owner = await read(await send("owner", { method: "DELETE", path: route.path, headers: good() }));
312
+ check(owner.status === 405, `DELETE ${route.path} as owner: expected 405, got ${owner.status}`);
313
+ const visitor = await read(await send("signed_out", { method: "DELETE", path: route.path, headers: good() }));
314
+ check(visitor.status === 401, `DELETE ${route.path} signed out: expected 401, got ${visitor.status}`);
315
+ }
316
+ });
317
+
318
+ add("a body that is far too big is refused and changes nothing", async () => {
319
+ const big = JSON.stringify({ character: "own", pad: "x".repeat(100_000) });
320
+ for (const route of host.routes.filter((r) => r.method === "POST" && r.path.endsWith("/game"))) {
321
+ const got = await read(await send("owner", { method: "POST", path: route.path, headers: good(), body: big }));
322
+ check(got.status === 413 || got.status === 400, `POST ${route.path} with 100 KB: expected 413 or 400, got ${got.status}`);
323
+ }
324
+ });
325
+
326
+ return cases;
327
+ }
328
+
329
+ // ---- the store contract: every adapter (the memory store, D1, Firestore, a game's own) must pass these ----
330
+
331
+ /** What a test gives to run the store contract: the store, and a way to put rows in the game's database the way the game's own code does (the store has no write for them). */
332
+ export type StoreHarness = {
333
+ store: AdminStore;
334
+ seed(data: { orders?: OrderRow[]; owned?: { player: string; lookId: string; at: number }[]; rate?: { value: string; at: number } }): Promise<void>;
335
+ /** Every row that the store could change by mistake, as one text: the orders, the owned looks and the meta rows that are NOT the two settings. Same rows, same text. */
336
+ snapshot(): Promise<string>;
337
+ /** Only for a store with `recentLogins` and `signOutEverywhere`: put admin sign-ins in the game's database (the store only reads them). */
338
+ seedLogins?(rows: { at: number; country: string | null; browser: string }[]): Promise<void>;
339
+ /** Put this many admin sessions and this many player sessions in the game's database. */
340
+ seedSessions?(n: { admin: number; play: number }): Promise<void>;
341
+ /** How many admin sessions and how many player sessions the game's database holds now. */
342
+ sessionCounts?(): Promise<{ admin: number; play: number }>;
343
+ /** Put this many admin sign-in requests and this many game sign-in requests that were started and NOT delivered in the game's database. */
344
+ seedRequests?(n: { admin: number; game: number }): Promise<void>;
345
+ /** How many admin and game sign-in requests are still not delivered. */
346
+ pendingRequests?(): Promise<{ admin: number; game: number }>;
347
+ };
348
+
349
+ const rawOf = (xno: number): string => (BigInt(Math.round(xno * 10_000)) * 10n ** 26n).toString();
350
+
351
+ export function adminStoreConformance(make: () => Promise<StoreHarness>): ConformanceCase[] {
352
+ const cases: ConformanceCase[] = [];
353
+ const add = (name: string, run: (h: StoreHarness) => Promise<void>) => cases.push({ name, run: async () => run(await make()) });
354
+ const NOW = 1_800_000_000_000;
355
+ const A = `nano_1${"a".repeat(59)}`;
356
+ const B = `nano_3${"b".repeat(59)}`;
357
+ const order = (id: string, over: Partial<OrderRow>): OrderRow => ({ 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 });
358
+
359
+ add("a new store has no settings of its own", async ({ store }) => {
360
+ const s = await store.readSettings();
361
+ check(s.character === undefined && s.signin === undefined, `expected no settings, got ${JSON.stringify(s)}`);
362
+ });
363
+
364
+ add("a patch saves only what it names; the next patch keeps the rest", async ({ store }) => {
365
+ await store.saveSettings({ signin: "none" }, NOW);
366
+ let s = await store.readSettings();
367
+ check(s.signin === "none" && s.character === undefined, `after signin none: ${JSON.stringify(s)}`);
368
+ await store.saveSettings({ character: "own" }, NOW + 1);
369
+ s = await store.readSettings();
370
+ check(s.signin === "none" && s.character === "own", `after character own: ${JSON.stringify(s)}`);
371
+ await store.saveSettings({ signin: "exchange", character: "nanoterra" }, NOW + 2);
372
+ s = await store.readSettings();
373
+ check(s.signin === "exchange" && s.character === "nanoterra", `after both: ${JSON.stringify(s)}`);
374
+ });
375
+
376
+ add("an empty patch writes nothing and does not fail", async ({ store }) => {
377
+ await store.saveSettings({}, NOW);
378
+ const s = await store.readSettings();
379
+ check(s.character === undefined && s.signin === undefined, "an empty patch must save nothing");
380
+ });
381
+
382
+ add("saving changes nothing but the two settings: no order, no owned look, no other meta row", async (h) => {
383
+ 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 } });
384
+ const before = await h.snapshot();
385
+ await h.store.saveSettings({ character: "own", signin: "none" }, NOW + 5);
386
+ check((await h.snapshot()) === before, "the rows of the game changed after saving the settings");
387
+ });
388
+
389
+ add("the summary counts the orders by status, reads an open order past its expiry as expired, and totals the money", async ({ store, seed }) => {
390
+ await seed({
391
+ orders: [
392
+ order("a", { status: "paid", usd: "0.25", amountRaw: rawOf(0.25) }),
393
+ order("b", { status: "paid", usd: "0.50", amountRaw: rawOf(0.5) }),
394
+ order("c", { status: "paid_late", usd: "1.10", amountRaw: rawOf(0.9) }),
395
+ order("d", { status: "open", expiresAt: NOW + 1000, paidAt: null, payer: null, hash: null }),
396
+ order("e", { status: "open", expiresAt: NOW - 1000, paidAt: null, payer: null, hash: null }),
397
+ order("f", { status: "expired", expiresAt: NOW - 5000, paidAt: null, payer: null, hash: null }),
398
+ ],
399
+ });
400
+ const s = await store.summary(NOW);
401
+ check(JSON.stringify(s.counts) === JSON.stringify({ open: 1, paid: 2, expired: 2, paid_late: 1 }), `counts: ${JSON.stringify(s.counts)}`);
402
+ check(JSON.stringify(s.totals.paid) === JSON.stringify({ count: 2, xno: "0.75", usd: "0.75" }), `paid: ${JSON.stringify(s.totals.paid)}`);
403
+ check(JSON.stringify(s.totals.paidLate) === JSON.stringify({ count: 1, xno: "0.9", usd: "1.10" }), `paid late: ${JSON.stringify(s.totals.paidLate)}`);
404
+ const e = s.orders.find((o) => o.id === "e");
405
+ check(e?.status === "expired", `an open order past its expiry reads "expired", got ${e?.status}`);
406
+ check(s.orders.find((o) => o.id === "d")?.status === "open", "an open order that has not run out stays open");
407
+ const a = s.orders.find((o) => o.id === "a")!;
408
+ 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)}`);
409
+ });
410
+
411
+ add(`the summary lists the newest ${ORDERS_SHOWN} orders, newest first, and still counts them all`, async ({ store, seed }) => {
412
+ const rows: OrderRow[] = [];
413
+ for (let i = 0; i < ORDERS_SHOWN + 5; i++) rows.push(order(`o${String(i).padStart(4, "0")}`, { createdAt: NOW - 100_000 + i, status: "paid", hash: `H${i}` }));
414
+ await seed({ orders: rows });
415
+ const s = await store.summary(NOW);
416
+ check(s.orders.length === ORDERS_SHOWN, `expected ${ORDERS_SHOWN} orders, got ${s.orders.length}`);
417
+ 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}`);
418
+ check(s.orders.every((o, i, all) => i === 0 || all[i - 1].createdAt >= o.createdAt), "the orders are not newest first");
419
+ check(s.counts.paid === ORDERS_SHOWN + 5, `the count must be over all orders, got ${s.counts.paid}`);
420
+ check(s.totals.paid.count === ORDERS_SHOWN + 5, `the total must be over all orders, got ${s.totals.paid.count}`);
421
+ });
422
+
423
+ add("the summary lists the looks that players own, by player and in the order they were granted", async ({ store, seed }) => {
424
+ 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 }] });
425
+ const s = await store.summary(NOW);
426
+ 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));
427
+ });
428
+
429
+ add("the rate is null when there is none, read when it is good, and null when the row is junk", async ({ store, seed }) => {
430
+ check((await store.summary(NOW)).rate === null, "no rate row must give null");
431
+ await seed({ rate: { value: JSON.stringify({ usd: 1.18, sources: ["a", "b"] }), at: NOW - 60 } });
432
+ 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");
433
+ await seed({ rate: { value: "not json", at: NOW } });
434
+ check((await store.summary(NOW)).rate === null, "a junk rate row must give null");
435
+ });
436
+
437
+ // 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).
438
+ 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) => {
439
+ if (!h.store.recentLogins) return;
440
+ check(Boolean(h.seedLogins), "a store with recentLogins needs seedLogins in its harness");
441
+ check((await h.store.recentLogins(10)).length === 0, "a new store lists no sign-ins");
442
+ const rows = Array.from({ length: 12 }, (_, i) => ({ at: NOW - 1000 * (12 - i), country: i % 3 === 0 ? null : "US", browser: `Chrome on macOS ${i}` }));
443
+ await h.seedLogins!(rows);
444
+ const got = await h.store.recentLogins(10);
445
+ check(got.length === 10, `expected 10 sign-ins, got ${got.length}`);
446
+ check(got.every((l, i, all) => i === 0 || all[i - 1].at >= l.at), "the sign-ins are not newest first");
447
+ check(got[0].at === NOW - 1000 && got[0].browser === "Chrome on macOS 11", `the first must be the newest: ${JSON.stringify(got[0])}`);
448
+ 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])}`);
449
+ check((await h.store.recentLogins(3)).length === 3, "the limit must hold");
450
+ check((await h.store.recentLogins(0)).length === 0, "a limit of 0 lists none");
451
+ });
452
+
453
+ 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) => {
454
+ if (!h.store.signOutEverywhere) return;
455
+ check(Boolean(h.seedSessions && h.sessionCounts), "a store with signOutEverywhere needs seedSessions and sessionCounts in its harness");
456
+ 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");
457
+ await h.seed({ orders: [order("1", {}), order("2", { status: "open" })], owned: [{ player: A, lookId: "mint-cap", at: NOW }] });
458
+ await h.seedSessions!({ admin: 3, play: 4 });
459
+ await h.seedRequests!({ admin: 2, game: 3 });
460
+ const before = await h.snapshot();
461
+ const n = await h.store.signOutEverywhere();
462
+ check(n === 3, `expected 3 admin sessions deleted, got ${n}`);
463
+ const left = await h.sessionCounts!();
464
+ check(left.admin === 0 && left.play === 4, `expected 0 admin and 4 player sessions, got ${JSON.stringify(left)}`);
465
+ check((await h.snapshot()) === before, "the orders, the owned looks or the meta rows changed");
466
+ check((await h.store.signOutEverywhere()) === 0, "a second call deletes nothing and says 0");
467
+ check((await h.sessionCounts!()).play === 4, "the player sessions are still there");
468
+ const p = await h.pendingRequests!();
469
+ 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)`);
470
+ check(p.game === 3, `the game's own sign-in requests must be left alone, ${p.game} of 3 are left`);
471
+ });
472
+
473
+ return cases;
474
+ }
475
+
476
+ // ---- the sign-in chain contract: the code that MAKES the logins ----
477
+ // The suite above proves what the admin does with a login. This one proves how the game MAKES one: a request started for the game never makes an admin login, a request started for
478
+ // the admin never makes a game login, and a request signs in only the browser that started it, whatever browser asks and whatever credentials it presents. The game gives a host that
479
+ // drives its own sign-in (start, the wallet's signature, the check). A refused attempt must leave the request as it was: after every refused attempt the contract proves that the signature
480
+ // had landed (the request still signs in its own browser), so a chain whose `sign` silently stops working cannot pass.
481
+
482
+ export type SigninScope = "game" | "admin";
483
+
484
+ export type SigninHost = {
485
+ /** Starts a sign-in request for this scope in a NEW browser, and returns a handle (the host keeps that browser's credentials in it). */
486
+ start(scope: SigninScope): Promise<unknown>;
487
+ /** The owner's wallet signs the request (what the wallet app does). It MUST throw when the answer was refused: a signature that did not land is a fault of the test, never a pass. */
488
+ sign(request: unknown): Promise<void>;
489
+ /**
490
+ * The browser asks to finish the request AS `as` (the scope the check is made for), and says which logins it ended with.
491
+ * - "own": the browser that started it, with its own credentials;
492
+ * - "other": a different browser with no credentials of this request;
493
+ * - "retyped": the browser that started it, presenting its credentials as the OTHER scope would read them (the cookie copied to the other scope's name; a value that the other
494
+ * scope's hashing would match: the game's best attempt at a forged credential). It differs from "own" only when `as` is not the scope the request started for.
495
+ */
496
+ finish(request: unknown, as: SigninScope, credentials: "own" | "other" | "retyped"): Promise<{ admin: boolean; game: boolean }>;
497
+ /** Optional, but pass `{ signOutEverywhere: true }` to the contract when your admin has "Sign out everywhere". Signs out everywhere (from a fresh admin login of the owner). */
498
+ signOutEverywhere?(): Promise<void>;
499
+ /**
500
+ * Optional, recommended (pass `{ advance: true }` to the contract when the host has it: a host that then has none fails). Moves the game's clock forward by `ms` for every later call. The contract then repeats the cross-scope asks after the admin's window has ended, inside the
501
+ * game's hour of delivery: a scope check that only works while a request is fresh fails here. The contract runs this case last.
502
+ */
503
+ advance?(ms: number): Promise<void>;
504
+ };
505
+
506
+ export function adminSigninConformance(host: SigninHost, options: { signOutEverywhere?: boolean; advance?: boolean } = {}): ConformanceCase[] {
507
+ const cases: ConformanceCase[] = [];
508
+ const add = (name: string, run: () => Promise<void>) => cases.push({ name, run });
509
+ const SCOPES = ["game", "admin"] as const;
510
+ const other = (s: SigninScope): SigninScope => (s === "admin" ? "game" : "admin");
511
+ const none = (got: { admin: boolean; game: boolean }) => !got.admin && !got.game;
512
+ const owns = (got: { admin: boolean; game: boolean }, scope: SigninScope) => (scope === "admin" ? got.admin && !got.game : got.game && !got.admin);
513
+
514
+ /** 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. */
515
+ async function stillSignsIn(req: unknown, scope: SigninScope, after: string): Promise<void> {
516
+ const got = await host.finish(req, scope, "own");
517
+ 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)`);
518
+ }
519
+
520
+ add("control: a request signed in its own scope signs in the browser that started it, in that scope only, once", async () => {
521
+ for (const scope of SCOPES) {
522
+ const req = await host.start(scope);
523
+ await host.sign(req);
524
+ const got = await host.finish(req, scope, "own");
525
+ check(owns(got, scope), `a ${scope} request finished as ${scope} by its own browser: ${JSON.stringify(got)}`);
526
+ const again = await host.finish(req, scope, "own");
527
+ check(none(again), `a ${scope} request must sign in once: the second check gave ${JSON.stringify(again)}`);
528
+ const stranger = await host.finish(req, scope, "other");
529
+ check(none(stranger), `a ${scope} request that was already collected must give another browser no login either: ${JSON.stringify(stranger)}`);
530
+ }
531
+ });
532
+
533
+ add("a request signs in only the browser that started it: another browser that knows the request signs in nobody, in either scope", async () => {
534
+ for (const scope of SCOPES) {
535
+ const req = await host.start(scope);
536
+ await host.sign(req);
537
+ const got = await host.finish(req, scope, "other");
538
+ check(none(got), `a ${scope} request finished in its own scope by ANOTHER browser made a login: ${JSON.stringify(got)}`);
539
+ await stillSignsIn(req, scope, "another browser asked in the same scope");
540
+ }
541
+ });
542
+
543
+ 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 () => {
544
+ for (const scope of SCOPES) {
545
+ for (const credentials of ["own", "other", "retyped"] as const) {
546
+ const req = await host.start(scope);
547
+ await host.sign(req);
548
+ const got = await host.finish(req, other(scope), credentials);
549
+ check(none(got), `a ${scope} request finished as the ${other(scope)} with ${credentials} credentials made a login: ${JSON.stringify(got)}`);
550
+ await stillSignsIn(req, scope, `an ask as the ${other(scope)} with ${credentials} credentials`);
551
+ }
552
+ }
553
+ });
554
+
555
+ 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 () => {
556
+ if (!host.signOutEverywhere) {
557
+ check(!options.signOutEverywhere, "the host has no signOutEverywhere, but the contract was told that the admin has Sign out everywhere");
558
+ return;
559
+ }
560
+ // signed, then signed out everywhere
561
+ const signed = await host.start("admin");
562
+ await host.sign(signed);
563
+ // waiting when signed out everywhere, signed afterwards
564
+ const waiting = await host.start("admin");
565
+ // a game request, signed, left alone
566
+ const game = await host.start("game");
567
+ await host.sign(game);
568
+ await host.signOutEverywhere();
569
+ check(none(await host.finish(signed, "admin", "own")), "a signed admin request that was not yet delivered made a login after Sign out everywhere");
570
+ try {
571
+ await host.sign(waiting); // the app may refuse it (the request ended): that is fine
572
+ } catch {
573
+ // refused
574
+ }
575
+ 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");
576
+ check(owns(await host.finish(game, "game", "own"), "game"), "Sign out everywhere must leave the game's own sign-in requests alone");
577
+ });
578
+
579
+ 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 () => {
580
+ if (!host.advance) {
581
+ check(!options.advance, "the host has no advance, but the contract was told that the game's clock can be moved");
582
+ return;
583
+ }
584
+ const made: { req: unknown; scope: SigninScope }[] = [];
585
+ for (const scope of SCOPES) {
586
+ const req = await host.start(scope);
587
+ await host.sign(req);
588
+ made.push({ req, scope });
589
+ }
590
+ // 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).
591
+ let waited = 0;
592
+ for (const minutes of [10, 55]) {
593
+ await host.advance((minutes - waited) * 60_000);
594
+ waited = minutes;
595
+ for (const { req, scope } of made) {
596
+ for (const credentials of ["own", "other", "retyped"] as const) {
597
+ const got = await host.finish(req, other(scope), credentials);
598
+ check(none(got), `${minutes} minutes later, a ${scope} request finished as the ${other(scope)} with ${credentials} credentials made a login: ${JSON.stringify(got)}`);
599
+ }
600
+ }
601
+ }
602
+ });
603
+
604
+ return cases;
605
+ }