@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,86 @@
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
+
8
+ /** 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. */
9
+ export type AdminSession = {
10
+ address: string;
11
+ createdAt: number;
12
+ /** "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. */
13
+ via: string;
14
+ };
15
+
16
+ export type AdminAccessInput = {
17
+ /** 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). */
18
+ owner: string | null | undefined;
19
+ /** 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. */
20
+ session: AdminSession | null | undefined;
21
+ /** The time now, in ms. */
22
+ now: number;
23
+ /** How old an admin login may be, in ms. At most one day: a bigger number is cut to one day, never allowed. */
24
+ maxAgeMs?: number;
25
+ };
26
+
27
+ /** Why a visitor was refused: the status to send and a word a page can act on. */
28
+ export type AdminRefusal = {
29
+ ok: false;
30
+ status: 401 | 403;
31
+ error: string;
32
+ /** "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. */
33
+ reason?: "no_address" | "nanoterra" | "other_address" | "misconfigured";
34
+ /** For "other_address": the address that is signed in (the page says "you are signed in as ..."). */
35
+ signedInAs?: string;
36
+ };
37
+ export type AdminAccess = { ok: true } | AdminRefusal;
38
+
39
+ /** An admin login counts for one day; then the owner signs in again. */
40
+ export const ADMIN_SESSION_MS = 24 * 60 * 60_000;
41
+ /** A login made in the future by this much or more is not believed (clocks of two servers differ by seconds, not by minutes). */
42
+ export const CLOCK_SKEW_MS = 5 * 60_000;
43
+
44
+ /** 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. */
45
+ export const OWNER_PATTERN = /^nano_[13][13-9a-km-uw-z]{59}$/;
46
+
47
+ const refuse = (status: 401 | 403, error: string, extra: Partial<AdminRefusal> = {}): AdminRefusal => ({ ok: false, status, error, ...extra });
48
+ const misconfigured = (): AdminRefusal => refuse(403, "The admin page is not set up correctly. Nobody can open it until it is.", { reason: "misconfigured" });
49
+
50
+ /** `ok: true` only when the request may read and change the admin data. */
51
+ export function adminAccess(input: AdminAccessInput): AdminAccess {
52
+ try {
53
+ return decide(input);
54
+ } catch {
55
+ // A getter that throws, a Proxy, anything odd: no.
56
+ return misconfigured();
57
+ }
58
+ }
59
+
60
+ function decide(input: AdminAccessInput): AdminAccess {
61
+ const { owner, session, now, maxAgeMs } = input;
62
+ if (typeof now !== "number" || !Number.isFinite(now)) return misconfigured();
63
+ let max = ADMIN_SESSION_MS;
64
+ if (maxAgeMs !== undefined) {
65
+ // 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.
66
+ if (typeof maxAgeMs !== "number" || !Number.isFinite(maxAgeMs) || maxAgeMs <= 0) return misconfigured();
67
+ max = Math.min(maxAgeMs, ADMIN_SESSION_MS);
68
+ }
69
+
70
+ // 1. The game has a real owner address.
71
+ if (typeof owner !== "string" || !OWNER_PATTERN.test(owner)) {
72
+ return refuse(403, "Set the game's own Nano address first. Only that address can open this page.", { reason: "no_address" });
73
+ }
74
+ // 2. There is an admin login.
75
+ if (!session || typeof session !== "object") return refuse(401, "Sign in with your Nano address.");
76
+ const { address, createdAt, via } = session;
77
+ if (typeof address !== "string" || typeof via !== "string" || typeof createdAt !== "number") return refuse(401, "Sign in with your Nano address.");
78
+ // 3. It was made with a wallet signature. A sign-in from Nanoterra names an address but proves no key.
79
+ if (via !== "wallet") return refuse(403, "Sign in with your Nano wallet to open this page.", { reason: "nanoterra" });
80
+ // 4. It is the owner's address (the same text, no other form).
81
+ if (address !== owner) return refuse(403, "Only the game's own address can open this page.", { reason: "other_address", signedInAs: address });
82
+ // 5. It is not older than the limit (one day at most), and not from the future.
83
+ const age = now - createdAt;
84
+ if (!(age >= -CLOCK_SKEW_MS && age <= max)) return refuse(401, "Sign in again: an admin sign-in counts for one day.");
85
+ return { ok: true };
86
+ }
@@ -0,0 +1,200 @@
1
+ // The admin handler: the routes of /admin on the Web `Request` and `Response` types, so it fits a Worker, Firebase Functions and Node.
2
+ //
3
+ // GET {base}/summary the numbers: status, orders, the looks that players own
4
+ // GET {base}/game the two switches
5
+ // POST {base}/game a press: { character } or { signin } (only the two known words)
6
+ // POST {base}/check the payment check, when the game gives one
7
+ //
8
+ // EVERY request under the base goes through `adminAccess` first, before the route is even looked up: a visitor who is not the owner gets 401 or 403
9
+ // for any path, a real one or not, and learns nothing about the routes. The handler sets no CORS header. A POST must come from the game's own page.
10
+
11
+ import { adminAccess, type AdminAccess, type AdminSession } from "./guard.js";
12
+ import { fail, foreignOrigin, json, memoryLimiter, readLimited, sameOrigin } from "./http.js";
13
+ import { ADMIN_LOGINS_SHOWN, type AdminLogin, type AdminOrder, type AdminPlayer, type AdminStatus, type AdminStore, type AdminSummary, type StoreSummary } from "./store.js";
14
+ import { mergeSettings, parsePatch, type GameSettings, type GameState } from "./switches.js";
15
+
16
+ export type AdminRoute = { method: "GET" | "POST"; path: string };
17
+
18
+ export type AdminHandlerOptions = {
19
+ /** The game's database. */
20
+ store: AdminStore;
21
+ /** What a switch is while /admin has not set it: the game's own words (game.config.json in the starter). */
22
+ defaults: GameSettings;
23
+ /** The game's own address ("nano_...", lowercase, its checksum already checked by the host), or null when none is set. */
24
+ owner: string | null;
25
+ /** 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). */
26
+ session: (req: Request, now: number) => Promise<AdminSession | null>;
27
+ /** 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. */
28
+ status?: (now: number, summary: StoreSummary) => Promise<AdminStatus> | AdminStatus;
29
+ /** What a look is called, for the orders. Default: its id. */
30
+ lookName?: (id: string) => string | undefined;
31
+ /** The payment check (POST check). Its answer is sent as `scan`. With none given, the route does not exist. */
32
+ check?: (now: number) => Promise<unknown>;
33
+ /** Where the routes are. Default "/api/admin". */
34
+ basePath?: string;
35
+ /** How old an admin login may be, in ms: one day at most (a bigger number is cut to one day). */
36
+ maxAgeMs?: number;
37
+ /** 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). */
38
+ limit?: (key: string, limit: number, windowMs: number, now: number) => boolean | Promise<boolean>;
39
+ /** The time now in ms. Default Date.now. */
40
+ now?: () => number;
41
+ /** Told about a fault in the store or in a callback. The visitor gets a plain 500 and never sees the fault. */
42
+ onError?: (e: unknown) => void;
43
+ };
44
+
45
+ export type AdminHandler = {
46
+ /** Every route, so that a test can walk them (see "@nano133/nanoterra/admin/conformance"). */
47
+ routes: readonly AdminRoute[];
48
+ basePath: string;
49
+ /** 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). */
50
+ handle(req: Request): Promise<Response | null>;
51
+ };
52
+
53
+ /** A browser name as the list shows it: words ("Chrome on macOS"). Anything else a store gives (a raw browser text, an address) is shown as "Unknown browser". */
54
+ const BROWSER_NAME = /^[A-Za-z][A-Za-z ]{0,39}$/;
55
+
56
+ /** The limits of the writes. */
57
+ export const ADMIN_LIMITS = {
58
+ /** A press on a switch. */
59
+ game: { limit: 30, windowMs: 60_000 },
60
+ /** A payment check. */
61
+ check: { limit: 20, windowMs: 60_000 },
62
+ /** "Sign out everywhere". */
63
+ signOutAll: { limit: 5, windowMs: 60_000 },
64
+ /** The biggest body a press can have. */
65
+ gameBodyMax: 512,
66
+ } as const;
67
+
68
+ /**
69
+ * Whether a request path could be read as under the admin base by anything: in any case, with backslashes for slashes, doubled slashes, and percent codes (decoded again and
70
+ * again, a code at a time, so one bad code does not stop the rest). Used ONLY to decide that a request is the admin's, so that the guard answers it. A path that is still
71
+ * changing after ten decodings is not an honest path: it is the admin's too (the guard refuses it). The route match later is exact on the parsed path.
72
+ */
73
+ function readsAsBase(raw: string, base: string): boolean {
74
+ let p = raw;
75
+ for (let i = 0; i < 10; i++) {
76
+ const plain = p.toLowerCase().replace(/\\/g, "/").replace(/\/{2,}/g, "/");
77
+ if (plain === base || plain.startsWith(`${base}/`)) return true;
78
+ const decoded = p.replace(/%([0-9a-f]{2})/gi, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
79
+ if (decoded === p) return false;
80
+ p = decoded;
81
+ }
82
+ return true;
83
+ }
84
+
85
+ /** The default limiter lives at module level: a Worker makes a handler for each request, and a limiter made inside it would forget every press. */
86
+ const sharedLimit = memoryLimiter();
87
+
88
+ export function createAdminHandler(options: AdminHandlerOptions): AdminHandler {
89
+ const base = (options.basePath ?? "/api/admin").toLowerCase();
90
+ if (!base.startsWith("/") || base.length < 2 || base.endsWith("/")) throw new Error('basePath must start with "/" and not end with "/", like "/api/admin"');
91
+ const now = options.now ?? (() => Date.now());
92
+ const limit = options.limit ?? sharedLimit;
93
+ /** The key of a limit: the base path and the owner come first, so two games in one process never share a count. */
94
+ const lkey = (name: string) => `${base}|${options.owner ?? ""}|${name}`;
95
+ const routes: AdminRoute[] = [
96
+ { method: "GET", path: `${base}/summary` },
97
+ { method: "GET", path: `${base}/game` },
98
+ { method: "POST", path: `${base}/game` },
99
+ ...(options.check ? [{ method: "POST" as const, path: `${base}/check` }] : []),
100
+ ...(options.store.signOutEverywhere ? [{ method: "POST" as const, path: `${base}/signout-all` }] : []),
101
+ ];
102
+
103
+ const settings = async (): Promise<GameState> => mergeSettings(await options.store.readSettings(), options.defaults);
104
+
105
+ async function summary(at: number): Promise<AdminSummary> {
106
+ const s = await options.store.summary(at);
107
+ const status = options.status ? await options.status(at, s) : { address: options.owner, testMode: false, warnings: [] as string[] };
108
+ const name = (id: string) => options.lookName?.(id) ?? id;
109
+ const orders: AdminOrder[] = s.orders.map((o) => ({ ...o, look: name(o.lookId) }));
110
+ const players: AdminPlayer[] = s.players.map((p) => ({ player: p.player, looks: p.looks.map((l) => ({ id: l.id, name: name(l.id), at: l.at })) }));
111
+ const out: AdminSummary = { ...status, counts: s.counts, totals: s.totals, rate: s.rate, orders, players, canCheck: Boolean(options.check) };
112
+ if (options.store.recentLogins) {
113
+ // Only the three things of a sign-in go out, whatever the store gave (a store that returns an address or a browser text by mistake does not show it).
114
+ const rows = await options.store.recentLogins(ADMIN_LOGINS_SHOWN);
115
+ out.logins = rows.slice(0, ADMIN_LOGINS_SHOWN).map((l): AdminLogin => ({ at: Number(l.at), country: typeof l.country === "string" && /^[A-Z]{2}$/.test(l.country) ? l.country : null, browser: typeof l.browser === "string" && BROWSER_NAME.test(l.browser) ? l.browser : "Unknown browser" }));
116
+ }
117
+ if (options.store.signOutEverywhere) out.canSignOutEverywhere = true;
118
+ return out;
119
+ }
120
+
121
+ /** Tells the game about a fault. A fault in the game's own `onError` is not the visitor's business: it is swallowed. */
122
+ const report = (e: unknown): void => {
123
+ try {
124
+ options.onError?.(e);
125
+ } catch {
126
+ // nothing more can be done
127
+ }
128
+ };
129
+
130
+ async function handle(req: Request): Promise<Response | null> {
131
+ const url = new URL(req.url);
132
+ if (!readsAsBase(url.pathname, base)) return null;
133
+ try {
134
+ const at = now();
135
+ // 1. The guard, before anything else.
136
+ let session: AdminSession | null;
137
+ try {
138
+ session = await options.session(req, at);
139
+ } catch (e) {
140
+ report(e);
141
+ // The game could not read its sessions: nobody is let in.
142
+ return fail(503, "The game's server could not check your sign-in. Try again.");
143
+ }
144
+ const access: AdminAccess = adminAccess({ owner: options.owner, session, now: at, maxAgeMs: options.maxAgeMs });
145
+ if (!access.ok) {
146
+ const { status, error, reason, signedInAs } = access;
147
+ return fail(status, error, { ...(reason ? { reason } : {}), ...(signedInAs ? { signedInAs } : {}) });
148
+ }
149
+ // 2. The route: an exact path (as the handler lists it), else 404 or 405.
150
+ const exact = url.pathname;
151
+ const known = routes.filter((r) => r.path === exact);
152
+ if (!known.length) return fail(404, "No such admin page.");
153
+ const route = known.find((r) => r.method === req.method);
154
+ if (!route) return fail(405, "Not allowed.", {}, { Allow: known.map((r) => r.method).join(", ") });
155
+ // 3. The call must come from the game's own page: a write must say so (Origin, or Sec-Fetch-Site same-origin); a read must not say it comes from another page.
156
+ if (route.method === "POST" ? !sameOrigin(req) : foreignOrigin(req)) return fail(403, "This call must come from the game's own page.");
157
+
158
+ if (route.method === "GET" && route.path === `${base}/summary`) return json(await summary(at));
159
+ if (route.method === "GET") return json(await settings());
160
+ if (route.path === `${base}/game`) {
161
+ if (!(await limit(lkey("admin-game"), ADMIN_LIMITS.game.limit, ADMIN_LIMITS.game.windowMs, at))) return fail(429, "Too many presses. Wait a minute.");
162
+ const text = await readLimited(req, ADMIN_LIMITS.gameBodyMax);
163
+ if (text === null) return fail(413, "That is too much to send.");
164
+ let body: unknown;
165
+ try {
166
+ body = JSON.parse(text);
167
+ } catch {
168
+ return fail(400, "Send JSON.");
169
+ }
170
+ const parsed = parsePatch(body);
171
+ if (!parsed.ok) return fail(400, parsed.error);
172
+ await options.store.saveSettings(parsed.patch, at);
173
+ return json(await settings());
174
+ }
175
+ if (route.path === `${base}/signout-all`) {
176
+ if (!(await limit(lkey("admin-signout-all"), ADMIN_LIMITS.signOutAll.limit, ADMIN_LIMITS.signOutAll.windowMs, at))) return fail(429, "Too many tries. Wait a minute.");
177
+ const n = await options.store.signOutEverywhere!();
178
+ return json({ ok: true, signedOut: n });
179
+ }
180
+ // POST check
181
+ if (!(await limit(lkey("admin-check"), ADMIN_LIMITS.check.limit, ADMIN_LIMITS.check.windowMs, at))) return fail(429, "Too many checks. Wait a minute.");
182
+ const scan = await options.check!(at);
183
+ return json({ scan, summary: await summary(now()) });
184
+ } catch (e) {
185
+ report(e);
186
+ return fail(500, "The game's server could not do that. Nothing was changed that you can see here.");
187
+ }
188
+ }
189
+
190
+ return { routes, basePath: options.basePath ?? "/api/admin", handle };
191
+ }
192
+
193
+ /** 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. */
194
+ export async function loadGameSettings(store: Pick<AdminStore, "readSettings">, defaults: GameSettings): Promise<GameState> {
195
+ try {
196
+ return mergeSettings(await store.readSettings(), defaults);
197
+ } catch {
198
+ return mergeSettings({}, defaults);
199
+ }
200
+ }
@@ -0,0 +1,74 @@
1
+ // Small helpers for the handler. Web APIs only (they run in Workers, Firebase Functions and Node).
2
+
3
+ export function json(data: unknown, status = 200, headers: Record<string, string> = {}): Response {
4
+ return new Response(JSON.stringify(data), {
5
+ status,
6
+ headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...headers },
7
+ });
8
+ }
9
+
10
+ export const fail = (status: number, error: string, extra: Record<string, unknown> = {}, headers: Record<string, string> = {}): Response => json({ error, ...extra }, status, headers);
11
+
12
+ /**
13
+ * True when the browser says the request comes from this game's own pages. A call that changes something must pass this, so another site
14
+ * cannot make the owner's browser do it. The browser sets Origin on a POST; a request with no Origin must say Sec-Fetch-Site: same-origin.
15
+ */
16
+ export function sameOrigin(req: Request): boolean {
17
+ const origin = req.headers.get("Origin");
18
+ if (origin !== null) return origin === new URL(req.url).origin;
19
+ return req.headers.get("Sec-Fetch-Site") === "same-origin";
20
+ }
21
+
22
+ /**
23
+ * True when the browser says a request did NOT come from this game's own pages: an Origin that is not the game's (any value, "null" too), or Sec-Fetch-Site "cross-site" or
24
+ * "same-site" (a sibling subdomain is same-site and has the game's cookies). A request with neither header (a command-line tool; a browser's own address bar says "none") is not foreign.
25
+ * The admin API is called only by its own page, so a read that is foreign is refused too: whatever CORS headers the game's router might add, the answer is not for another page.
26
+ */
27
+ export function foreignOrigin(req: Request): boolean {
28
+ const origin = req.headers.get("Origin");
29
+ if (origin !== null && origin !== new URL(req.url).origin) return true;
30
+ const site = req.headers.get("Sec-Fetch-Site");
31
+ return site === "cross-site" || site === "same-site";
32
+ }
33
+
34
+ /** The text of a body of at most `max` bytes, or null when it is bigger. It stops reading at the limit: a body with no length cannot fill the memory. */
35
+ export async function readLimited(req: Request, max: number): Promise<string | null> {
36
+ const declared = req.headers.get("Content-Length");
37
+ if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > max)) return null;
38
+ if (!req.body) return "";
39
+ const reader = req.body.getReader();
40
+ const parts: Uint8Array[] = [];
41
+ let size = 0;
42
+ for (;;) {
43
+ const { done, value } = await reader.read();
44
+ if (done) break;
45
+ size += value.byteLength;
46
+ if (size > max) {
47
+ await reader.cancel().catch(() => undefined);
48
+ return null;
49
+ }
50
+ parts.push(value);
51
+ }
52
+ const all = new Uint8Array(size);
53
+ let at = 0;
54
+ for (const p of parts) {
55
+ all.set(p, at);
56
+ at += p.byteLength;
57
+ }
58
+ return new TextDecoder().decode(all);
59
+ }
60
+
61
+ /** A fixed-window limiter in memory. True while `key` has made at most `limit` calls in this window. Each copy of a server counts for itself. */
62
+ export function memoryLimiter(): (key: string, limit: number, windowMs: number, now: number) => boolean {
63
+ const hits = new Map<string, { count: number; until: number }>();
64
+ return (key, limit, windowMs, now) => {
65
+ const hit = hits.get(key);
66
+ if (!hit || hit.until <= now) {
67
+ if (hits.size > 1000) for (const [k, v] of hits) if (v.until <= now) hits.delete(k);
68
+ hits.set(key, { count: 1, until: now + windowMs });
69
+ return true;
70
+ }
71
+ hit.count += 1;
72
+ return hit.count <= limit;
73
+ };
74
+ }
@@ -0,0 +1,21 @@
1
+ // "@nano133/nanoterra/admin": the admin of a game, in the package. No Three.js, no database, no cookie: the game gives a session and a store.
2
+ //
3
+ // adminAccess the guard: one pure function, who may open /admin
4
+ // createAdminHandler the routes, on Request and Response
5
+ // loadGameSettings the two switches for the game's own routes
6
+ // createMemoryStore a store in memory, for tests
7
+ // (the conformance suite is its own path: "@nano133/nanoterra/admin/conformance"; the adapters are "/admin/d1" and "/admin/firestore")
8
+
9
+ export { ADMIN_SESSION_MS, CLOCK_SKEW_MS, OWNER_PATTERN, adminAccess } from "./guard.js";
10
+ export type { AdminAccess, AdminAccessInput, AdminRefusal, AdminSession } from "./guard.js";
11
+ export { ADMIN_LIMITS, createAdminHandler, loadGameSettings } from "./handler.js";
12
+ export type { AdminHandler, AdminHandlerOptions, AdminRoute } from "./handler.js";
13
+ export { createMemoryStore } from "./memory.js";
14
+ export type { MemoryStore } from "./memory.js";
15
+ export { ADMIN_LOGINS_SHOWN } from "./store.js";
16
+ export type { AdminLogin, AdminOrder, AdminPlayer, AdminStatus, AdminStore, AdminSummary, Money, OrderStatus, StoreSummary } from "./store.js";
17
+ export { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers, statusOf, summarize } from "./summary.js";
18
+ export type { OrderRow } from "./summary.js";
19
+ export { RAW_PER_XNO, xnoText } from "./amount.js";
20
+ export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
21
+ export type { Character, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
@@ -0,0 +1,59 @@
1
+ // A store in memory, for tests (and for a game's first try). Nothing is kept after the process ends. It builds its summary with the same code as the
2
+ // real adapters (./summary.ts), so it is the reference for what a summary says.
3
+
4
+ import { summarize, type OrderRow } from "./summary.js";
5
+ import type { AdminLogin, AdminStore, StoreSummary } from "./store.js";
6
+ import type { GamePatch } from "./switches.js";
7
+
8
+ export type MemoryStore = AdminStore & {
9
+ /** The rows as an adapter would keep them in its meta table. */
10
+ readonly rows: Map<string, string>;
11
+ /** How many writes were made. */
12
+ writes: number;
13
+ orders: OrderRow[];
14
+ owned: { player: string; lookId: string; at: number }[];
15
+ rate: StoreSummary["rate"];
16
+ /** The admin sign-ins and the count of admin sessions, when the store was made with `logins: true`. */
17
+ logins: (AdminLogin & Record<string, unknown>)[];
18
+ adminSessions: number;
19
+ playSessions: number;
20
+ /** Admin sign-in requests started and not delivered, and the game's own: "sign out everywhere" ends the first and leaves the second. */
21
+ pendingAdminRequests: number;
22
+ pendingGameRequests: number;
23
+ };
24
+
25
+ export function createMemoryStore(seed: { orders?: OrderRow[]; owned?: { player: string; lookId: string; at: number }[]; rate?: StoreSummary["rate"]; logins?: boolean } = {}): MemoryStore {
26
+ const store: MemoryStore = {
27
+ rows: new Map(),
28
+ writes: 0,
29
+ orders: seed.orders ?? [],
30
+ owned: seed.owned ?? [],
31
+ rate: seed.rate ?? null,
32
+ logins: [],
33
+ adminSessions: 0,
34
+ playSessions: 0,
35
+ pendingAdminRequests: 0,
36
+ pendingGameRequests: 0,
37
+ async readSettings() {
38
+ return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin") };
39
+ },
40
+ async saveSettings(patch: GamePatch) {
41
+ if (patch.character) store.rows.set("setting:character", patch.character);
42
+ if (patch.signin) store.rows.set("setting:signin", patch.signin);
43
+ store.writes++;
44
+ },
45
+ async summary(now: number): Promise<StoreSummary> {
46
+ return summarize(store.orders, store.owned, store.rate, now);
47
+ },
48
+ };
49
+ if (seed.logins) {
50
+ store.recentLogins = async (limit: number) => [...store.logins].sort((a, b) => b.at - a.at).slice(0, limit);
51
+ store.signOutEverywhere = async () => {
52
+ const n = store.adminSessions;
53
+ store.adminSessions = 0;
54
+ store.pendingAdminRequests = 0;
55
+ return n;
56
+ };
57
+ }
58
+ return store;
59
+ }
@@ -0,0 +1,37 @@
1
+ // Calls to the game's own server. Cookies stay on this site; nothing goes to another server.
2
+
3
+ export class ApiError extends Error {
4
+ constructor(
5
+ public status: number,
6
+ message: string,
7
+ public body: Record<string, unknown> = {},
8
+ ) {
9
+ super(message);
10
+ }
11
+ }
12
+
13
+ export type Api = {
14
+ get<T>(path: string): Promise<T>;
15
+ post<T>(path: string, body?: unknown): Promise<T>;
16
+ };
17
+
18
+ /** The calls of the admin: `base` is where the routes are ("/api/admin"), `send` is `fetch` (a test gives its own). */
19
+ export function createApi(base: string, send: typeof fetch = (...a) => fetch(...a)): Api {
20
+ async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
21
+ const res = await send(`${base}${path}`, { credentials: "same-origin", ...init, headers: { "Content-Type": "application/json", ...(init.headers ?? {}) } });
22
+ const text = await res.text();
23
+ let body: Record<string, unknown> = {};
24
+ try {
25
+ const v: unknown = text ? JSON.parse(text) : {};
26
+ if (v && typeof v === "object") body = v as Record<string, unknown>;
27
+ } catch {
28
+ body = {};
29
+ }
30
+ if (!res.ok) throw new ApiError(res.status, typeof body.error === "string" ? body.error : `The server answered ${res.status}. Try again.`, body);
31
+ return body as T;
32
+ }
33
+ return {
34
+ get: (path) => call(path),
35
+ post: (path, body = {}) => call(path, { method: "POST", body: JSON.stringify(body) }),
36
+ };
37
+ }
@@ -0,0 +1,31 @@
1
+ // A tiny helper to build page elements without innerHTML (safe for any text, and fine under a strict CSP). The panel runs in a browser.
2
+
3
+ type Attrs = Record<string, string | number | boolean | undefined | ((e: Event) => void)>;
4
+ type Child = Node | string | null | undefined | false;
5
+
6
+ export function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Attrs = {}, ...children: Child[]): HTMLElementTagNameMap[K] {
7
+ const el = document.createElement(tag);
8
+ for (const [k, v] of Object.entries(attrs)) {
9
+ if (v === undefined || v === false) continue;
10
+ if (typeof v === "function") el.addEventListener(k.replace(/^on/, ""), v);
11
+ else if (k === "class") el.className = String(v);
12
+ else if (v === true) el.setAttribute(k, "");
13
+ else el.setAttribute(k, String(v));
14
+ }
15
+ for (const c of children) if (c !== null && c !== undefined && c !== false) el.append(c);
16
+ return el;
17
+ }
18
+
19
+ /** "nano_1abc…wxyz" */
20
+ export const shortAddress = (a: string): string => `${a.slice(0, 10)}…${a.slice(-6)}`;
21
+
22
+ export async function copy(text: string, button: HTMLButtonElement): Promise<void> {
23
+ const was = button.textContent;
24
+ try {
25
+ await navigator.clipboard.writeText(text);
26
+ button.textContent = "Copied";
27
+ } catch {
28
+ button.textContent = "Copy failed";
29
+ }
30
+ setTimeout(() => (button.textContent = was), 1500);
31
+ }
@@ -0,0 +1,63 @@
1
+ // The "Game" card of /admin: two switches, "Players use their Nanoterra character" and "Players can sign in with Nanoterra". A press saves at once
2
+ // (POST {api}/game, behind the same admin guard as every admin call) and the game reads the setting at run time, so the creator pushes and deploys
3
+ // nothing. The words are in ../switches.ts. The card changes ONLY these two settings: never a look, a price or an order.
4
+
5
+ import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf, type GameState, type SwitchId } from "../switches.js";
6
+ import { ApiError, type Api } from "./api.js";
7
+ import { h } from "./dom.js";
8
+
9
+ /** The card for the settings `initial`. It keeps its own state: a press shows "Saving…", then "Saved." or the server's words. `defaultsFile` is where the game's defaults are written ("game.config.json"). */
10
+ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game's own settings"): HTMLElement {
11
+ let state = initial;
12
+ const rows = new Map<SwitchId, { button: HTMLButtonElement; sentence: HTMLElement; status: HTMLElement }>();
13
+ /** Under the character switch: it works only through sign-in, so it says so, and says what a player gets while sign-in is Off. */
14
+ const needs = h("p", { class: "fine switch-needs" });
15
+ const card = h("section", { class: "admin-card game-card", "aria-labelledby": "game-title" }, h("h2", { id: "game-title" }, "Game"));
16
+ card.append(h("p", { class: "fine" }, `Two choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
17
+
18
+ const draw = () => {
19
+ for (const [id, row] of rows) {
20
+ const on = stateOf(id, state);
21
+ row.button.setAttribute("aria-checked", on ? "true" : "false");
22
+ row.button.classList.toggle("switch-on", on);
23
+ row.button.querySelector(".switch-word")!.textContent = on ? "On" : "Off";
24
+ row.sentence.textContent = on ? SWITCHES[id].on : SWITCHES[id].off;
25
+ }
26
+ needs.hidden = !stateOf("character", state);
27
+ needs.textContent = signinOn(state.signin) ? CHARACTER_NEEDS_SIGNIN : CHARACTER_SIGNIN_OFF;
28
+ };
29
+
30
+ const press = async (id: SwitchId) => {
31
+ const row = rows.get(id)!;
32
+ row.button.disabled = true;
33
+ row.status.textContent = "Saving…";
34
+ try {
35
+ state = await api.post<GameState>("/game", pressOf(id, state));
36
+ row.status.textContent = "Saved.";
37
+ draw();
38
+ } catch (e) {
39
+ row.status.textContent = e instanceof ApiError ? e.message : "The game's server did not answer. Nothing was changed.";
40
+ } finally {
41
+ row.button.disabled = false;
42
+ }
43
+ };
44
+
45
+ for (const id of ["character", "signin"] as const) {
46
+ const label = h("span", { class: "switch-title", id: `switch-${id}-label` }, SWITCHES[id].title);
47
+ const button = h(
48
+ "button",
49
+ { class: "switch", type: "button", role: "switch", "aria-checked": "false", "aria-labelledby": `switch-${id}-label`, "aria-describedby": `switch-${id}-note` },
50
+ h("span", { class: "switch-word" }, "Off"),
51
+ h("span", { class: "switch-knob", "aria-hidden": "true" }),
52
+ );
53
+ const sentence = h("p", { class: "switch-note", id: `switch-${id}-note` });
54
+ const status = h("span", { class: "fine", role: "status", "aria-live": "polite" });
55
+ button.addEventListener("click", () => void press(id));
56
+ rows.set(id, { button, sentence, status });
57
+ card.append(h("div", { class: "switch-row" }, h("div", { class: "switch-head" }, label, button), sentence, id === "character" ? needs : null, status));
58
+ // The sign-in word is part of the listing file: Nanoterra's review decides what Nanoterra shows. This page cannot see that review.
59
+ if (id === "signin") card.append(h("p", { class: "fine switch-review" }, REVIEW_NOTE, " ", h("a", { href: REVIEW_LINK, target: "_blank", rel: "noopener noreferrer" }, "Open the launch page")));
60
+ }
61
+ draw();
62
+ return card;
63
+ }
@@ -0,0 +1,18 @@
1
+ // "@nano133/nanoterra/admin/panel": the admin page of a game, drawn in the browser into one element. Plain DOM, no framework, no Three.js.
2
+ //
3
+ // import { mountAdmin } from "@nano133/nanoterra/admin/panel";
4
+ // import "@nano133/nanoterra/admin/style.css";
5
+ // mountAdmin(document.getElementById("admin")!, { looks, signIn, signOut });
6
+ //
7
+ // The server part is "@nano133/nanoterra/admin" (the guard and the handler): it must not load this one.
8
+
9
+ export { mountAdmin } from "./mount.js";
10
+ export type { MountedAdmin, MountOptions } from "./mount.js";
11
+ export { ApiError, createApi } from "./api.js";
12
+ export type { Api } from "./api.js";
13
+ export { gameCard } from "./gameCard.js";
14
+ export { lookPicture, shelfView } from "./shelf.js";
15
+ export { DEFAULT_LOGINS_NOTE, signinsCard } from "./signins.js";
16
+ export type { ShelfData } from "./shelf.js";
17
+ export { aiPrompt, centsOf, changeText, ownedIds, shelfStats, soldText } from "./shelfData.js";
18
+ export type { LookStats, ShelfOrder, ShelfPlayer } from "./shelfData.js";