@nano133/nanoterra 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.1 (2026-11-01)
4
+
5
+ - **The admin conformance suite's heavy checks are one case for each route.** "Other spellings of EVERY admin path" and "a request from another
6
+ page" were each one case over every route, every visitor and every method: hundreds of requests, which timed out at the starter's 30 s under load
7
+ and even alone on a game with more routes (the prompt pack's Echo Island runs). They are now "other spellings of GET /api/admin/summary ..." and
8
+ "a request from another page to GET /api/admin/summary ...", one for each route; the checks are the same. `ConformanceCase` has an optional
9
+ `timeoutMs`, and the heavy cases (these two and "OPTIONS and HEAD") set it to the new `HEAVY_CASE_MS` (120 s): pass it to the runner,
10
+ `it(c.name, c.run, c.timeoutMs)`.
11
+ - README: a test of the shop's sheet clicks a slot's tab (`.n133s-tab`) before it looks for that slot's looks, or calls `buy(lookId)`.
12
+
3
13
  ## 0.10.0 (2026-11-01)
4
14
 
5
15
  - **No minimum price: a fraction of a cent works.** A look's `priceUsd` may have up to 4 decimals (`0.005` is half a cent, `0.0001` the smallest price
package/README.md CHANGED
@@ -544,9 +544,13 @@ and your defaults when the store fails.
544
544
  import { adminConformance } from "@nano133/nanoterra/admin/conformance";
545
545
 
546
546
  const host = { routes: admin.routes, basePath: admin.basePath, origin: "https://game.example", request: (who, req) => sendAs(who, req) };
547
- for (const c of adminConformance(host)) it(c.name, c.run); // vitest, node:test or any runner
547
+ for (const c of adminConformance(host)) it(c.name, c.run, c.timeoutMs); // vitest; node:test: test(c.name, { timeout: c.timeoutMs }, c.run)
548
548
  ```
549
549
 
550
+ Pass `c.timeoutMs`: the heavy cases (other spellings of a path, a request from another page, OPTIONS and HEAD) each send every visitor with every
551
+ method, a few hundred requests, and say they may need `HEAVY_CASE_MS` (120 s) on a slow machine. They are one case for each route (since 0.10.1),
552
+ so a game with many routes has many short cases, not one long one.
553
+
550
554
  `sendAs(who, req)` makes a request as one of eleven visitors from YOUR sessions: `signed_out`; `game_login` (a login made for your players, which must NOT open the admin);
551
555
  `game_token_as_admin` (the token of that player login put in the ADMIN cookie: your store must keep the two scopes apart, not only the cookie names); `nanoterra_login`;
552
556
  `unknown_via_login` (an admin login whose `via` is a word you do not know, like `"ticket"`: it must fail closed); `other_wallet`; `no_address`;
@@ -1318,7 +1322,9 @@ const shop = mountShop(document.getElementById("shop")!, {
1318
1322
  ```
1319
1323
 
1320
1324
  - The looks sheet: a tab for each wear slot (the outfits first) when the game sells items, each look with its price before any step; Wear,
1321
- Wearing, or Buy. `picture(look)` gives the game's own picture instead of the swatch.
1325
+ Wearing, or Buy. `picture(look)` gives the game's own picture instead of the swatch. **For a test:** the sheet opens on the first tab (the
1326
+ outfits), so the looks of another slot ("Shirts", "Hats") are not in the page until the test clicks their tab (`.n133s-tab`); or the test
1327
+ calls `buy(lookId)`, which needs no tab.
1322
1328
  - Buy: the game's sign-in (`signIn`) when nobody is signed in, then the person check, then the pay sheet: the amount, the QR code of the exact
1323
1329
  `nano:` link (drawn by the package as SVG: no QR library, no outside service), "Open wallet", the address and the amount by hand, the time
1324
1330
  left (live only), and a check every 5 seconds for 2 minutes ("Check again" after). Paid: "Paid. Thank you!" and `onOwned`. Paid late: no look,
@@ -51,10 +51,17 @@ export type ConformanceHost = {
51
51
  /** 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. */
52
52
  endLogin?(who: Who): Promise<void>;
53
53
  };
54
+ /**
55
+ * One test case. `timeoutMs`, when set, is the time the case may need on a slow machine (each heavy case sends a few hundred requests to the game):
56
+ * pass it to the runner, `it(c.name, c.run, c.timeoutMs)` (vitest) or `test(c.name, { timeout: c.timeoutMs }, c.run)` (node:test).
57
+ */
54
58
  export type ConformanceCase = {
55
59
  name: string;
56
60
  run: () => Promise<void>;
61
+ timeoutMs?: number;
57
62
  };
63
+ /** The time a heavy case may take (every visitor, every method, many spellings or origins of one route). */
64
+ export declare const HEAVY_CASE_MS = 120000;
58
65
  export declare function adminConformance(host: ConformanceHost): ConformanceCase[];
59
66
  /** 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). */
60
67
  export type StoreHarness = {
@@ -8,6 +8,8 @@
8
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
9
  // only for statuses and for the words "reason" and "error"; it never reads a cookie.
10
10
  import { ORDERS_SHOWN } from "./summary.js";
11
+ /** The time a heavy case may take (every visitor, every method, many spellings or origins of one route). */
12
+ export const HEAVY_CASE_MS = 120_000;
11
13
  const REFUSALS = [
12
14
  { who: "signed_out", status: 401, words: "a visitor who is signed out" },
13
15
  { who: "game_login", status: 401, words: "a login that was made for the game's players" },
@@ -43,7 +45,7 @@ const REFUSAL_KEYS = new Set(["error", "reason", "signedInAs"]);
43
45
  const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"emotes"|"warnings"/;
44
46
  export function adminConformance(host) {
45
47
  const cases = [];
46
- const add = (name, run) => cases.push({ name, run });
48
+ const add = (name, run, timeoutMs) => cases.push(timeoutMs ? { name, run, timeoutMs } : { name, run });
47
49
  const good = (extra = {}) => ({ Origin: host.origin, "Content-Type": "application/json", ...extra });
48
50
  const body = (r) => (r.method === "POST" ? "{}" : undefined);
49
51
  const NOT_OWNER = REFUSALS.map((r) => r.who);
@@ -86,7 +88,7 @@ export function adminConformance(host) {
86
88
  }
87
89
  }
88
90
  }
89
- });
91
+ }, HEAVY_CASE_MS);
90
92
  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
93
  const path = `${host.basePath}/no-such-page`;
92
94
  for (const r of REFUSALS) {
@@ -124,13 +126,10 @@ export function adminConformance(host) {
124
126
  `${base.replace(/^\/(\w)/, (_, c) => `/%${c.charCodeAt(0).toString(16)}`)}/${last}`,
125
127
  ];
126
128
  };
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);
129
+ // One case for each route (not one for all of them): each sends every visitor with every method, so a game with many routes stays inside a runner's time.
130
+ for (const route of host.routes) {
131
+ add(`other spellings of ${route.method} ${route.path} never open anything for a visitor, with any method`, async () => {
132
+ for (const path of new Set(spellings(route.path))) {
134
133
  for (const who of NOT_OWNER) {
135
134
  for (const method of new Set([route.method, "GET", "POST", "OPTIONS", "HEAD"])) {
136
135
  const got = await read(await send(who, { method, path, headers: good(), body: body({ method }) }, false));
@@ -139,13 +138,13 @@ export function adminConformance(host) {
139
138
  }
140
139
  }
141
140
  }
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) {
141
+ }, HEAVY_CASE_MS);
142
+ }
143
+ const own = new URL(host.origin);
144
+ const origins = ["https://evil.example", "null", `${own.protocol}//evil.${own.host}`, `${own.protocol}//${own.host.replace(/^[^.]+\./, "sibling.")}`, `${own.protocol}//${own.host}.evil.example`];
145
+ const WHO = ["owner", ...NOT_OWNER];
146
+ for (const route of host.routes) {
147
+ add(`a request from another page to ${route.method} ${route.path} never gets a CORS header, and never the data: a foreign Origin (another site, 'null', a sibling subdomain), with every method, for the owner and for every visitor`, async () => {
149
148
  for (const origin of origins) {
150
149
  for (const who of WHO) {
151
150
  for (const method of new Set([route.method, "GET", "OPTIONS", "HEAD"])) {
@@ -161,8 +160,8 @@ export function adminConformance(host) {
161
160
  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
161
  check(got.status === 403, `${route.method} ${route.path} as owner with Sec-Fetch-Site ${site}: expected 403, got ${got.status}`);
163
162
  }
164
- }
165
- });
163
+ }, HEAVY_CASE_MS);
164
+ }
166
165
  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
166
  for (const route of host.routes) {
168
167
  for (const again of [false, true, true]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano133/nanoterra",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton), the looks ladder, the admin, the friends list, an options menu, emotes, the player's sign-in (a passkey or a wallet app) and a shop that sells looks for Nano with no key on the server.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -62,7 +62,14 @@ export type ConformanceHost = {
62
62
  endLogin?(who: Who): Promise<void>;
63
63
  };
64
64
 
65
- export type ConformanceCase = { name: string; run: () => Promise<void> };
65
+ /**
66
+ * One test case. `timeoutMs`, when set, is the time the case may need on a slow machine (each heavy case sends a few hundred requests to the game):
67
+ * pass it to the runner, `it(c.name, c.run, c.timeoutMs)` (vitest) or `test(c.name, { timeout: c.timeoutMs }, c.run)` (node:test).
68
+ */
69
+ export type ConformanceCase = { name: string; run: () => Promise<void>; timeoutMs?: number };
70
+
71
+ /** The time a heavy case may take (every visitor, every method, many spellings or origins of one route). */
72
+ export const HEAVY_CASE_MS = 120_000;
66
73
 
67
74
  const REFUSALS: { who: Who; status: number; reason?: string; words: string }[] = [
68
75
  { who: "signed_out", status: 401, words: "a visitor who is signed out" },
@@ -101,7 +108,7 @@ const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"f
101
108
 
102
109
  export function adminConformance(host: ConformanceHost): ConformanceCase[] {
103
110
  const cases: ConformanceCase[] = [];
104
- const add = (name: string, run: () => Promise<void>) => cases.push({ name, run });
111
+ const add = (name: string, run: () => Promise<void>, timeoutMs?: number) => cases.push(timeoutMs ? { name, run, timeoutMs } : { name, run });
105
112
  const good = (extra: Record<string, string> = {}) => ({ Origin: host.origin, "Content-Type": "application/json", ...extra });
106
113
  const body = (r: { method: string }) => (r.method === "POST" ? "{}" : undefined);
107
114
  const NOT_OWNER = REFUSALS.map((r) => r.who);
@@ -145,7 +152,7 @@ export function adminConformance(host: ConformanceHost): ConformanceCase[] {
145
152
  }
146
153
  }
147
154
  }
148
- });
155
+ }, HEAVY_CASE_MS);
149
156
 
150
157
  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
158
  const path = `${host.basePath}/no-such-page`;
@@ -187,12 +194,10 @@ export function adminConformance(host: ConformanceHost): ConformanceCase[] {
187
194
  ];
188
195
  };
189
196
 
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);
197
+ // One case for each route (not one for all of them): each sends every visitor with every method, so a game with many routes stays inside a runner's time.
198
+ for (const route of host.routes) {
199
+ add(`other spellings of ${route.method} ${route.path} never open anything for a visitor, with any method`, async () => {
200
+ for (const path of new Set(spellings(route.path))) {
196
201
  for (const who of NOT_OWNER) {
197
202
  for (const method of new Set([route.method, "GET", "POST", "OPTIONS", "HEAD"])) {
198
203
  const got = await read(await send(who, { method, path, headers: good(), body: body({ method }) }, false));
@@ -201,14 +206,14 @@ export function adminConformance(host: ConformanceHost): ConformanceCase[] {
201
206
  }
202
207
  }
203
208
  }
204
- }
205
- });
209
+ }, HEAVY_CASE_MS);
210
+ }
206
211
 
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
+ const own = new URL(host.origin);
213
+ const origins = ["https://evil.example", "null", `${own.protocol}//evil.${own.host}`, `${own.protocol}//${own.host.replace(/^[^.]+\./, "sibling.")}`, `${own.protocol}//${own.host}.evil.example`];
214
+ const WHO: Who[] = ["owner", ...NOT_OWNER];
215
+ for (const route of host.routes) {
216
+ add(`a request from another page to ${route.method} ${route.path} never gets a CORS header, and never the data: a foreign Origin (another site, 'null', a sibling subdomain), with every method, for the owner and for every visitor`, async () => {
212
217
  for (const origin of origins) {
213
218
  for (const who of WHO) {
214
219
  for (const method of new Set([route.method, "GET", "OPTIONS", "HEAD"])) {
@@ -224,8 +229,8 @@ export function adminConformance(host: ConformanceHost): ConformanceCase[] {
224
229
  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
230
  check(got.status === 403, `${route.method} ${route.path} as owner with Sec-Fetch-Site ${site}: expected 403, got ${got.status}`);
226
231
  }
227
- }
228
- });
232
+ }, HEAVY_CASE_MS);
233
+ }
229
234
 
230
235
  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
236
  for (const route of host.routes) {