@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 +10 -0
- package/README.md +8 -2
- package/dist/admin/conformance.d.ts +7 -0
- package/dist/admin/conformance.js +17 -18
- package/package.json +1 -1
- package/src/admin/conformance.ts +23 -18
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
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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.
|
|
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",
|
package/src/admin/conformance.ts
CHANGED
|
@@ -62,7 +62,14 @@ export type ConformanceHost = {
|
|
|
62
62
|
endLogin?(who: Who): Promise<void>;
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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) {
|