@nano133/nanoterra 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/README.md +269 -12
- package/ai/SPEC.md +7 -2
- package/dist/admin/adapters/d1.d.ts +27 -0
- package/dist/admin/adapters/d1.js +73 -0
- package/dist/admin/adapters/firestore.d.ts +40 -0
- package/dist/admin/adapters/firestore.js +53 -0
- package/dist/admin/amount.d.ts +5 -0
- package/dist/admin/amount.js +11 -0
- package/dist/admin/conformance.d.ts +132 -0
- package/dist/admin/conformance.js +467 -0
- package/dist/admin/guard.d.ts +38 -0
- package/dist/admin/guard.js +57 -0
- package/dist/admin/handler.d.ts +63 -0
- package/dist/admin/handler.js +170 -0
- package/dist/admin/http.d.ts +17 -0
- package/dist/admin/http.js +76 -0
- package/dist/admin/index.d.ts +13 -0
- package/dist/admin/index.js +14 -0
- package/dist/admin/memory.d.ts +32 -0
- package/dist/admin/memory.js +40 -0
- package/dist/admin/panel/api.d.ts +11 -0
- package/dist/admin/panel/api.js +33 -0
- package/dist/admin/panel/dom.d.ts +7 -0
- package/dist/admin/panel/dom.js +33 -0
- package/dist/admin/panel/gameCard.d.ts +4 -0
- package/dist/admin/panel/gameCard.js +56 -0
- package/dist/admin/panel/index.d.ts +10 -0
- package/dist/admin/panel/index.js +13 -0
- package/dist/admin/panel/mount.d.ts +30 -0
- package/dist/admin/panel/mount.js +147 -0
- package/dist/admin/panel/shelf.d.ts +24 -0
- package/dist/admin/panel/shelf.js +271 -0
- package/dist/admin/panel/shelfData.d.ts +37 -0
- package/dist/admin/panel/shelfData.js +68 -0
- package/dist/admin/panel/signins.d.ts +6 -0
- package/dist/admin/panel/signins.js +69 -0
- package/dist/admin/store.d.ts +121 -0
- package/dist/admin/store.js +3 -0
- package/dist/admin/summary.d.ts +39 -0
- package/dist/admin/summary.js +85 -0
- package/dist/admin/switches.d.ts +58 -0
- package/dist/admin/switches.js +61 -0
- package/dist/character/bases/bee.js +7 -5
- package/dist/character/bases/spec.d.ts +7 -0
- package/dist/character/create.d.ts +15 -0
- package/dist/character/create.js +69 -2
- package/dist/character/ctx.d.ts +12 -2
- package/dist/character/index.d.ts +2 -0
- package/dist/character/index.js +2 -0
- package/dist/character/make.js +2 -1
- package/dist/character/preview.d.ts +32 -0
- package/dist/character/preview.js +112 -0
- package/dist/character/sockets.d.ts +37 -0
- package/dist/character/sockets.js +66 -0
- package/dist/looks/change.d.ts +8 -4
- package/dist/looks/change.js +25 -3
- package/dist/looks/index.d.ts +3 -3
- package/dist/looks/index.js +2 -2
- package/dist/looks/json.d.ts +2 -2
- package/dist/looks/json.js +6 -4
- package/dist/looks/look.d.ts +29 -3
- package/dist/looks/look.js +60 -4
- package/dist/model/check.d.ts +59 -0
- package/dist/model/check.js +681 -0
- package/dist/model/index.d.ts +2 -0
- package/dist/model/index.js +5 -0
- package/dist/model/limits.d.ts +103 -0
- package/dist/model/limits.js +132 -0
- package/package.json +11 -3
- package/src/admin/adapters/d1.ts +94 -0
- package/src/admin/adapters/firestore.ts +78 -0
- package/src/admin/amount.ts +14 -0
- package/src/admin/conformance.ts +605 -0
- package/src/admin/guard.ts +86 -0
- package/src/admin/handler.ts +200 -0
- package/src/admin/http.ts +74 -0
- package/src/admin/index.ts +21 -0
- package/src/admin/memory.ts +59 -0
- package/src/admin/panel/api.ts +37 -0
- package/src/admin/panel/dom.ts +31 -0
- package/src/admin/panel/gameCard.ts +63 -0
- package/src/admin/panel/index.ts +18 -0
- package/src/admin/panel/mount.ts +227 -0
- package/src/admin/panel/shelf.ts +361 -0
- package/src/admin/panel/shelfData.ts +87 -0
- package/src/admin/panel/signins.ts +68 -0
- package/src/admin/panel/style.css +551 -0
- package/src/admin/store.ts +98 -0
- package/src/admin/summary.ts +103 -0
- package/src/admin/switches.ts +74 -0
- package/src/character/bases/bee.ts +7 -5
- package/src/character/bases/spec.ts +4 -0
- package/src/character/create.ts +73 -2
- package/src/character/ctx.ts +17 -2
- package/src/character/index.ts +2 -0
- package/src/character/make.ts +2 -1
- package/src/character/preview.ts +148 -0
- package/src/character/sockets.ts +81 -0
- package/src/looks/change.ts +25 -6
- package/src/looks/index.ts +3 -3
- package/src/looks/json.ts +6 -4
- package/src/looks/look.ts +66 -5
- package/src/model/check.ts +624 -0
- package/src/model/index.ts +5 -0
- package/src/model/limits.ts +193 -0
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
import { adminAccess } from "./guard.js";
|
|
11
|
+
import { fail, foreignOrigin, json, memoryLimiter, readLimited, sameOrigin } from "./http.js";
|
|
12
|
+
import { ADMIN_LOGINS_SHOWN } from "./store.js";
|
|
13
|
+
import { mergeSettings, parsePatch } from "./switches.js";
|
|
14
|
+
/** 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". */
|
|
15
|
+
const BROWSER_NAME = /^[A-Za-z][A-Za-z ]{0,39}$/;
|
|
16
|
+
/** The limits of the writes. */
|
|
17
|
+
export const ADMIN_LIMITS = {
|
|
18
|
+
/** A press on a switch. */
|
|
19
|
+
game: { limit: 30, windowMs: 60_000 },
|
|
20
|
+
/** A payment check. */
|
|
21
|
+
check: { limit: 20, windowMs: 60_000 },
|
|
22
|
+
/** "Sign out everywhere". */
|
|
23
|
+
signOutAll: { limit: 5, windowMs: 60_000 },
|
|
24
|
+
/** The biggest body a press can have. */
|
|
25
|
+
gameBodyMax: 512,
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* 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
|
|
29
|
+
* 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
|
|
30
|
+
* 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.
|
|
31
|
+
*/
|
|
32
|
+
function readsAsBase(raw, base) {
|
|
33
|
+
let p = raw;
|
|
34
|
+
for (let i = 0; i < 10; i++) {
|
|
35
|
+
const plain = p.toLowerCase().replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
|
36
|
+
if (plain === base || plain.startsWith(`${base}/`))
|
|
37
|
+
return true;
|
|
38
|
+
const decoded = p.replace(/%([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
39
|
+
if (decoded === p)
|
|
40
|
+
return false;
|
|
41
|
+
p = decoded;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
/** 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. */
|
|
46
|
+
const sharedLimit = memoryLimiter();
|
|
47
|
+
export function createAdminHandler(options) {
|
|
48
|
+
const base = (options.basePath ?? "/api/admin").toLowerCase();
|
|
49
|
+
if (!base.startsWith("/") || base.length < 2 || base.endsWith("/"))
|
|
50
|
+
throw new Error('basePath must start with "/" and not end with "/", like "/api/admin"');
|
|
51
|
+
const now = options.now ?? (() => Date.now());
|
|
52
|
+
const limit = options.limit ?? sharedLimit;
|
|
53
|
+
/** The key of a limit: the base path and the owner come first, so two games in one process never share a count. */
|
|
54
|
+
const lkey = (name) => `${base}|${options.owner ?? ""}|${name}`;
|
|
55
|
+
const routes = [
|
|
56
|
+
{ method: "GET", path: `${base}/summary` },
|
|
57
|
+
{ method: "GET", path: `${base}/game` },
|
|
58
|
+
{ method: "POST", path: `${base}/game` },
|
|
59
|
+
...(options.check ? [{ method: "POST", path: `${base}/check` }] : []),
|
|
60
|
+
...(options.store.signOutEverywhere ? [{ method: "POST", path: `${base}/signout-all` }] : []),
|
|
61
|
+
];
|
|
62
|
+
const settings = async () => mergeSettings(await options.store.readSettings(), options.defaults);
|
|
63
|
+
async function summary(at) {
|
|
64
|
+
const s = await options.store.summary(at);
|
|
65
|
+
const status = options.status ? await options.status(at, s) : { address: options.owner, testMode: false, warnings: [] };
|
|
66
|
+
const name = (id) => options.lookName?.(id) ?? id;
|
|
67
|
+
const orders = s.orders.map((o) => ({ ...o, look: name(o.lookId) }));
|
|
68
|
+
const players = s.players.map((p) => ({ player: p.player, looks: p.looks.map((l) => ({ id: l.id, name: name(l.id), at: l.at })) }));
|
|
69
|
+
const out = { ...status, counts: s.counts, totals: s.totals, rate: s.rate, orders, players, canCheck: Boolean(options.check) };
|
|
70
|
+
if (options.store.recentLogins) {
|
|
71
|
+
// 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).
|
|
72
|
+
const rows = await options.store.recentLogins(ADMIN_LOGINS_SHOWN);
|
|
73
|
+
out.logins = rows.slice(0, ADMIN_LOGINS_SHOWN).map((l) => ({ 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" }));
|
|
74
|
+
}
|
|
75
|
+
if (options.store.signOutEverywhere)
|
|
76
|
+
out.canSignOutEverywhere = true;
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** Tells the game about a fault. A fault in the game's own `onError` is not the visitor's business: it is swallowed. */
|
|
80
|
+
const report = (e) => {
|
|
81
|
+
try {
|
|
82
|
+
options.onError?.(e);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// nothing more can be done
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
async function handle(req) {
|
|
89
|
+
const url = new URL(req.url);
|
|
90
|
+
if (!readsAsBase(url.pathname, base))
|
|
91
|
+
return null;
|
|
92
|
+
try {
|
|
93
|
+
const at = now();
|
|
94
|
+
// 1. The guard, before anything else.
|
|
95
|
+
let session;
|
|
96
|
+
try {
|
|
97
|
+
session = await options.session(req, at);
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
report(e);
|
|
101
|
+
// The game could not read its sessions: nobody is let in.
|
|
102
|
+
return fail(503, "The game's server could not check your sign-in. Try again.");
|
|
103
|
+
}
|
|
104
|
+
const access = adminAccess({ owner: options.owner, session, now: at, maxAgeMs: options.maxAgeMs });
|
|
105
|
+
if (!access.ok) {
|
|
106
|
+
const { status, error, reason, signedInAs } = access;
|
|
107
|
+
return fail(status, error, { ...(reason ? { reason } : {}), ...(signedInAs ? { signedInAs } : {}) });
|
|
108
|
+
}
|
|
109
|
+
// 2. The route: an exact path (as the handler lists it), else 404 or 405.
|
|
110
|
+
const exact = url.pathname;
|
|
111
|
+
const known = routes.filter((r) => r.path === exact);
|
|
112
|
+
if (!known.length)
|
|
113
|
+
return fail(404, "No such admin page.");
|
|
114
|
+
const route = known.find((r) => r.method === req.method);
|
|
115
|
+
if (!route)
|
|
116
|
+
return fail(405, "Not allowed.", {}, { Allow: known.map((r) => r.method).join(", ") });
|
|
117
|
+
// 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.
|
|
118
|
+
if (route.method === "POST" ? !sameOrigin(req) : foreignOrigin(req))
|
|
119
|
+
return fail(403, "This call must come from the game's own page.");
|
|
120
|
+
if (route.method === "GET" && route.path === `${base}/summary`)
|
|
121
|
+
return json(await summary(at));
|
|
122
|
+
if (route.method === "GET")
|
|
123
|
+
return json(await settings());
|
|
124
|
+
if (route.path === `${base}/game`) {
|
|
125
|
+
if (!(await limit(lkey("admin-game"), ADMIN_LIMITS.game.limit, ADMIN_LIMITS.game.windowMs, at)))
|
|
126
|
+
return fail(429, "Too many presses. Wait a minute.");
|
|
127
|
+
const text = await readLimited(req, ADMIN_LIMITS.gameBodyMax);
|
|
128
|
+
if (text === null)
|
|
129
|
+
return fail(413, "That is too much to send.");
|
|
130
|
+
let body;
|
|
131
|
+
try {
|
|
132
|
+
body = JSON.parse(text);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return fail(400, "Send JSON.");
|
|
136
|
+
}
|
|
137
|
+
const parsed = parsePatch(body);
|
|
138
|
+
if (!parsed.ok)
|
|
139
|
+
return fail(400, parsed.error);
|
|
140
|
+
await options.store.saveSettings(parsed.patch, at);
|
|
141
|
+
return json(await settings());
|
|
142
|
+
}
|
|
143
|
+
if (route.path === `${base}/signout-all`) {
|
|
144
|
+
if (!(await limit(lkey("admin-signout-all"), ADMIN_LIMITS.signOutAll.limit, ADMIN_LIMITS.signOutAll.windowMs, at)))
|
|
145
|
+
return fail(429, "Too many tries. Wait a minute.");
|
|
146
|
+
const n = await options.store.signOutEverywhere();
|
|
147
|
+
return json({ ok: true, signedOut: n });
|
|
148
|
+
}
|
|
149
|
+
// POST check
|
|
150
|
+
if (!(await limit(lkey("admin-check"), ADMIN_LIMITS.check.limit, ADMIN_LIMITS.check.windowMs, at)))
|
|
151
|
+
return fail(429, "Too many checks. Wait a minute.");
|
|
152
|
+
const scan = await options.check(at);
|
|
153
|
+
return json({ scan, summary: await summary(now()) });
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
report(e);
|
|
157
|
+
return fail(500, "The game's server could not do that. Nothing was changed that you can see here.");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { routes, basePath: options.basePath ?? "/api/admin", handle };
|
|
161
|
+
}
|
|
162
|
+
/** 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. */
|
|
163
|
+
export async function loadGameSettings(store, defaults) {
|
|
164
|
+
try {
|
|
165
|
+
return mergeSettings(await store.readSettings(), defaults);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return mergeSettings({}, defaults);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare function json(data: unknown, status?: number, headers?: Record<string, string>): Response;
|
|
2
|
+
export declare const fail: (status: number, error: string, extra?: Record<string, unknown>, headers?: Record<string, string>) => Response;
|
|
3
|
+
/**
|
|
4
|
+
* 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
|
|
5
|
+
* 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.
|
|
6
|
+
*/
|
|
7
|
+
export declare function sameOrigin(req: Request): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* 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
|
|
10
|
+
* "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.
|
|
11
|
+
* 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.
|
|
12
|
+
*/
|
|
13
|
+
export declare function foreignOrigin(req: Request): boolean;
|
|
14
|
+
/** 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. */
|
|
15
|
+
export declare function readLimited(req: Request, max: number): Promise<string | null>;
|
|
16
|
+
/** 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. */
|
|
17
|
+
export declare function memoryLimiter(): (key: string, limit: number, windowMs: number, now: number) => boolean;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Small helpers for the handler. Web APIs only (they run in Workers, Firebase Functions and Node).
|
|
2
|
+
export function json(data, status = 200, headers = {}) {
|
|
3
|
+
return new Response(JSON.stringify(data), {
|
|
4
|
+
status,
|
|
5
|
+
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...headers },
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
export const fail = (status, error, extra = {}, headers = {}) => json({ error, ...extra }, status, headers);
|
|
9
|
+
/**
|
|
10
|
+
* 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
|
|
11
|
+
* 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.
|
|
12
|
+
*/
|
|
13
|
+
export function sameOrigin(req) {
|
|
14
|
+
const origin = req.headers.get("Origin");
|
|
15
|
+
if (origin !== null)
|
|
16
|
+
return origin === new URL(req.url).origin;
|
|
17
|
+
return req.headers.get("Sec-Fetch-Site") === "same-origin";
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 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
|
|
21
|
+
* "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.
|
|
22
|
+
* 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.
|
|
23
|
+
*/
|
|
24
|
+
export function foreignOrigin(req) {
|
|
25
|
+
const origin = req.headers.get("Origin");
|
|
26
|
+
if (origin !== null && origin !== new URL(req.url).origin)
|
|
27
|
+
return true;
|
|
28
|
+
const site = req.headers.get("Sec-Fetch-Site");
|
|
29
|
+
return site === "cross-site" || site === "same-site";
|
|
30
|
+
}
|
|
31
|
+
/** 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. */
|
|
32
|
+
export async function readLimited(req, max) {
|
|
33
|
+
const declared = req.headers.get("Content-Length");
|
|
34
|
+
if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > max))
|
|
35
|
+
return null;
|
|
36
|
+
if (!req.body)
|
|
37
|
+
return "";
|
|
38
|
+
const reader = req.body.getReader();
|
|
39
|
+
const parts = [];
|
|
40
|
+
let size = 0;
|
|
41
|
+
for (;;) {
|
|
42
|
+
const { done, value } = await reader.read();
|
|
43
|
+
if (done)
|
|
44
|
+
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
|
+
/** 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. */
|
|
61
|
+
export function memoryLimiter() {
|
|
62
|
+
const hits = new Map();
|
|
63
|
+
return (key, limit, windowMs, now) => {
|
|
64
|
+
const hit = hits.get(key);
|
|
65
|
+
if (!hit || hit.until <= now) {
|
|
66
|
+
if (hits.size > 1000)
|
|
67
|
+
for (const [k, v] of hits)
|
|
68
|
+
if (v.until <= now)
|
|
69
|
+
hits.delete(k);
|
|
70
|
+
hits.set(key, { count: 1, until: now + windowMs });
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
hit.count += 1;
|
|
74
|
+
return hit.count <= limit;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { ADMIN_SESSION_MS, CLOCK_SKEW_MS, OWNER_PATTERN, adminAccess } from "./guard.js";
|
|
2
|
+
export type { AdminAccess, AdminAccessInput, AdminRefusal, AdminSession } from "./guard.js";
|
|
3
|
+
export { ADMIN_LIMITS, createAdminHandler, loadGameSettings } from "./handler.js";
|
|
4
|
+
export type { AdminHandler, AdminHandlerOptions, AdminRoute } from "./handler.js";
|
|
5
|
+
export { createMemoryStore } from "./memory.js";
|
|
6
|
+
export type { MemoryStore } from "./memory.js";
|
|
7
|
+
export { ADMIN_LOGINS_SHOWN } from "./store.js";
|
|
8
|
+
export type { AdminLogin, AdminOrder, AdminPlayer, AdminStatus, AdminStore, AdminSummary, Money, OrderStatus, StoreSummary } from "./store.js";
|
|
9
|
+
export { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers, statusOf, summarize } from "./summary.js";
|
|
10
|
+
export type { OrderRow } from "./summary.js";
|
|
11
|
+
export { RAW_PER_XNO, xnoText } from "./amount.js";
|
|
12
|
+
export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
|
|
13
|
+
export type { Character, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
export { ADMIN_SESSION_MS, CLOCK_SKEW_MS, OWNER_PATTERN, adminAccess } from "./guard.js";
|
|
9
|
+
export { ADMIN_LIMITS, createAdminHandler, loadGameSettings } from "./handler.js";
|
|
10
|
+
export { createMemoryStore } from "./memory.js";
|
|
11
|
+
export { ADMIN_LOGINS_SHOWN } from "./store.js";
|
|
12
|
+
export { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers, statusOf, summarize } from "./summary.js";
|
|
13
|
+
export { RAW_PER_XNO, xnoText } from "./amount.js";
|
|
14
|
+
export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type OrderRow } from "./summary.js";
|
|
2
|
+
import type { AdminLogin, AdminStore, StoreSummary } from "./store.js";
|
|
3
|
+
export type MemoryStore = AdminStore & {
|
|
4
|
+
/** The rows as an adapter would keep them in its meta table. */
|
|
5
|
+
readonly rows: Map<string, string>;
|
|
6
|
+
/** How many writes were made. */
|
|
7
|
+
writes: number;
|
|
8
|
+
orders: OrderRow[];
|
|
9
|
+
owned: {
|
|
10
|
+
player: string;
|
|
11
|
+
lookId: string;
|
|
12
|
+
at: number;
|
|
13
|
+
}[];
|
|
14
|
+
rate: StoreSummary["rate"];
|
|
15
|
+
/** The admin sign-ins and the count of admin sessions, when the store was made with `logins: true`. */
|
|
16
|
+
logins: (AdminLogin & Record<string, unknown>)[];
|
|
17
|
+
adminSessions: number;
|
|
18
|
+
playSessions: number;
|
|
19
|
+
/** Admin sign-in requests started and not delivered, and the game's own: "sign out everywhere" ends the first and leaves the second. */
|
|
20
|
+
pendingAdminRequests: number;
|
|
21
|
+
pendingGameRequests: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function createMemoryStore(seed?: {
|
|
24
|
+
orders?: OrderRow[];
|
|
25
|
+
owned?: {
|
|
26
|
+
player: string;
|
|
27
|
+
lookId: string;
|
|
28
|
+
at: number;
|
|
29
|
+
}[];
|
|
30
|
+
rate?: StoreSummary["rate"];
|
|
31
|
+
logins?: boolean;
|
|
32
|
+
}): MemoryStore;
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
import { summarize } from "./summary.js";
|
|
4
|
+
export function createMemoryStore(seed = {}) {
|
|
5
|
+
const store = {
|
|
6
|
+
rows: new Map(),
|
|
7
|
+
writes: 0,
|
|
8
|
+
orders: seed.orders ?? [],
|
|
9
|
+
owned: seed.owned ?? [],
|
|
10
|
+
rate: seed.rate ?? null,
|
|
11
|
+
logins: [],
|
|
12
|
+
adminSessions: 0,
|
|
13
|
+
playSessions: 0,
|
|
14
|
+
pendingAdminRequests: 0,
|
|
15
|
+
pendingGameRequests: 0,
|
|
16
|
+
async readSettings() {
|
|
17
|
+
return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin") };
|
|
18
|
+
},
|
|
19
|
+
async saveSettings(patch) {
|
|
20
|
+
if (patch.character)
|
|
21
|
+
store.rows.set("setting:character", patch.character);
|
|
22
|
+
if (patch.signin)
|
|
23
|
+
store.rows.set("setting:signin", patch.signin);
|
|
24
|
+
store.writes++;
|
|
25
|
+
},
|
|
26
|
+
async summary(now) {
|
|
27
|
+
return summarize(store.orders, store.owned, store.rate, now);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
if (seed.logins) {
|
|
31
|
+
store.recentLogins = async (limit) => [...store.logins].sort((a, b) => b.at - a.at).slice(0, limit);
|
|
32
|
+
store.signOutEverywhere = async () => {
|
|
33
|
+
const n = store.adminSessions;
|
|
34
|
+
store.adminSessions = 0;
|
|
35
|
+
store.pendingAdminRequests = 0;
|
|
36
|
+
return n;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return store;
|
|
40
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare class ApiError extends Error {
|
|
2
|
+
status: number;
|
|
3
|
+
body: Record<string, unknown>;
|
|
4
|
+
constructor(status: number, message: string, body?: Record<string, unknown>);
|
|
5
|
+
}
|
|
6
|
+
export type Api = {
|
|
7
|
+
get<T>(path: string): Promise<T>;
|
|
8
|
+
post<T>(path: string, body?: unknown): Promise<T>;
|
|
9
|
+
};
|
|
10
|
+
/** The calls of the admin: `base` is where the routes are ("/api/admin"), `send` is `fetch` (a test gives its own). */
|
|
11
|
+
export declare function createApi(base: string, send?: typeof fetch): Api;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Calls to the game's own server. Cookies stay on this site; nothing goes to another server.
|
|
2
|
+
export class ApiError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
body;
|
|
5
|
+
constructor(status, message, body = {}) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.body = body;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** The calls of the admin: `base` is where the routes are ("/api/admin"), `send` is `fetch` (a test gives its own). */
|
|
12
|
+
export function createApi(base, send = (...a) => fetch(...a)) {
|
|
13
|
+
async function call(path, init = {}) {
|
|
14
|
+
const res = await send(`${base}${path}`, { credentials: "same-origin", ...init, headers: { "Content-Type": "application/json", ...(init.headers ?? {}) } });
|
|
15
|
+
const text = await res.text();
|
|
16
|
+
let body = {};
|
|
17
|
+
try {
|
|
18
|
+
const v = text ? JSON.parse(text) : {};
|
|
19
|
+
if (v && typeof v === "object")
|
|
20
|
+
body = v;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
body = {};
|
|
24
|
+
}
|
|
25
|
+
if (!res.ok)
|
|
26
|
+
throw new ApiError(res.status, typeof body.error === "string" ? body.error : `The server answered ${res.status}. Try again.`, body);
|
|
27
|
+
return body;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
get: (path) => call(path),
|
|
31
|
+
post: (path, body = {}) => call(path, { method: "POST", body: JSON.stringify(body) }),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
type Attrs = Record<string, string | number | boolean | undefined | ((e: Event) => void)>;
|
|
2
|
+
type Child = Node | string | null | undefined | false;
|
|
3
|
+
export declare function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs?: Attrs, ...children: Child[]): HTMLElementTagNameMap[K];
|
|
4
|
+
/** "nano_1abc…wxyz" */
|
|
5
|
+
export declare const shortAddress: (a: string) => string;
|
|
6
|
+
export declare function copy(text: string, button: HTMLButtonElement): Promise<void>;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
export function h(tag, attrs = {}, ...children) {
|
|
3
|
+
const el = document.createElement(tag);
|
|
4
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
5
|
+
if (v === undefined || v === false)
|
|
6
|
+
continue;
|
|
7
|
+
if (typeof v === "function")
|
|
8
|
+
el.addEventListener(k.replace(/^on/, ""), v);
|
|
9
|
+
else if (k === "class")
|
|
10
|
+
el.className = String(v);
|
|
11
|
+
else if (v === true)
|
|
12
|
+
el.setAttribute(k, "");
|
|
13
|
+
else
|
|
14
|
+
el.setAttribute(k, String(v));
|
|
15
|
+
}
|
|
16
|
+
for (const c of children)
|
|
17
|
+
if (c !== null && c !== undefined && c !== false)
|
|
18
|
+
el.append(c);
|
|
19
|
+
return el;
|
|
20
|
+
}
|
|
21
|
+
/** "nano_1abc…wxyz" */
|
|
22
|
+
export const shortAddress = (a) => `${a.slice(0, 10)}…${a.slice(-6)}`;
|
|
23
|
+
export async function copy(text, button) {
|
|
24
|
+
const was = button.textContent;
|
|
25
|
+
try {
|
|
26
|
+
await navigator.clipboard.writeText(text);
|
|
27
|
+
button.textContent = "Copied";
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
button.textContent = "Copy failed";
|
|
31
|
+
}
|
|
32
|
+
setTimeout(() => (button.textContent = was), 1500);
|
|
33
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type GameState } from "../switches.js";
|
|
2
|
+
import { type Api } from "./api.js";
|
|
3
|
+
/** 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"). */
|
|
4
|
+
export declare function gameCard(api: Api, initial: GameState, defaultsFile?: string): HTMLElement;
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf } from "../switches.js";
|
|
5
|
+
import { ApiError } from "./api.js";
|
|
6
|
+
import { h } from "./dom.js";
|
|
7
|
+
/** 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"). */
|
|
8
|
+
export function gameCard(api, initial, defaultsFile = "the game's own settings") {
|
|
9
|
+
let state = initial;
|
|
10
|
+
const rows = new Map();
|
|
11
|
+
/** 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. */
|
|
12
|
+
const needs = h("p", { class: "fine switch-needs" });
|
|
13
|
+
const card = h("section", { class: "admin-card game-card", "aria-labelledby": "game-title" }, h("h2", { id: "game-title" }, "Game"));
|
|
14
|
+
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.`));
|
|
15
|
+
const draw = () => {
|
|
16
|
+
for (const [id, row] of rows) {
|
|
17
|
+
const on = stateOf(id, state);
|
|
18
|
+
row.button.setAttribute("aria-checked", on ? "true" : "false");
|
|
19
|
+
row.button.classList.toggle("switch-on", on);
|
|
20
|
+
row.button.querySelector(".switch-word").textContent = on ? "On" : "Off";
|
|
21
|
+
row.sentence.textContent = on ? SWITCHES[id].on : SWITCHES[id].off;
|
|
22
|
+
}
|
|
23
|
+
needs.hidden = !stateOf("character", state);
|
|
24
|
+
needs.textContent = signinOn(state.signin) ? CHARACTER_NEEDS_SIGNIN : CHARACTER_SIGNIN_OFF;
|
|
25
|
+
};
|
|
26
|
+
const press = async (id) => {
|
|
27
|
+
const row = rows.get(id);
|
|
28
|
+
row.button.disabled = true;
|
|
29
|
+
row.status.textContent = "Saving…";
|
|
30
|
+
try {
|
|
31
|
+
state = await api.post("/game", pressOf(id, state));
|
|
32
|
+
row.status.textContent = "Saved.";
|
|
33
|
+
draw();
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
row.status.textContent = e instanceof ApiError ? e.message : "The game's server did not answer. Nothing was changed.";
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
row.button.disabled = false;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
for (const id of ["character", "signin"]) {
|
|
43
|
+
const label = h("span", { class: "switch-title", id: `switch-${id}-label` }, SWITCHES[id].title);
|
|
44
|
+
const button = h("button", { class: "switch", type: "button", role: "switch", "aria-checked": "false", "aria-labelledby": `switch-${id}-label`, "aria-describedby": `switch-${id}-note` }, h("span", { class: "switch-word" }, "Off"), h("span", { class: "switch-knob", "aria-hidden": "true" }));
|
|
45
|
+
const sentence = h("p", { class: "switch-note", id: `switch-${id}-note` });
|
|
46
|
+
const status = h("span", { class: "fine", role: "status", "aria-live": "polite" });
|
|
47
|
+
button.addEventListener("click", () => void press(id));
|
|
48
|
+
rows.set(id, { button, sentence, status });
|
|
49
|
+
card.append(h("div", { class: "switch-row" }, h("div", { class: "switch-head" }, label, button), sentence, id === "character" ? needs : null, status));
|
|
50
|
+
// The sign-in word is part of the listing file: Nanoterra's review decides what Nanoterra shows. This page cannot see that review.
|
|
51
|
+
if (id === "signin")
|
|
52
|
+
card.append(h("p", { class: "fine switch-review" }, REVIEW_NOTE, " ", h("a", { href: REVIEW_LINK, target: "_blank", rel: "noopener noreferrer" }, "Open the launch page")));
|
|
53
|
+
}
|
|
54
|
+
draw();
|
|
55
|
+
return card;
|
|
56
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { mountAdmin } from "./mount.js";
|
|
2
|
+
export type { MountedAdmin, MountOptions } from "./mount.js";
|
|
3
|
+
export { ApiError, createApi } from "./api.js";
|
|
4
|
+
export type { Api } from "./api.js";
|
|
5
|
+
export { gameCard } from "./gameCard.js";
|
|
6
|
+
export { lookPicture, previewPicture, shelfView } from "./shelf.js";
|
|
7
|
+
export { DEFAULT_LOGINS_NOTE, signinsCard } from "./signins.js";
|
|
8
|
+
export type { ShelfData } from "./shelf.js";
|
|
9
|
+
export { aiPrompt, centsOf, changeText, ownedIds, shelfStats, soldText } from "./shelfData.js";
|
|
10
|
+
export type { LookStats, ShelfOrder, ShelfPlayer } from "./shelfData.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
export { mountAdmin } from "./mount.js";
|
|
9
|
+
export { ApiError, createApi } from "./api.js";
|
|
10
|
+
export { gameCard } from "./gameCard.js";
|
|
11
|
+
export { lookPicture, previewPicture, shelfView } from "./shelf.js";
|
|
12
|
+
export { DEFAULT_LOGINS_NOTE, signinsCard } from "./signins.js";
|
|
13
|
+
export { aiPrompt, centsOf, changeText, ownedIds, shelfStats, soldText } from "./shelfData.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Look } from "../../looks/look.js";
|
|
2
|
+
export type MountOptions = {
|
|
3
|
+
/** The game's looks now (its looks.json). The Shelf starts from them. */
|
|
4
|
+
looks: readonly Look[];
|
|
5
|
+
/** Draws the wallet sign-in of the game into `into` and calls `done` when the owner has signed in. Only a wallet signature opens the admin: use the game's admin scope. */
|
|
6
|
+
signIn: (into: HTMLElement, done: () => void) => void;
|
|
7
|
+
/** Signs out of the ADMIN login only (the game's own login, another person's perhaps, stays). */
|
|
8
|
+
signOut: () => Promise<unknown>;
|
|
9
|
+
/** Where the routes are (the handler's basePath). Default "/api/admin". */
|
|
10
|
+
api?: string;
|
|
11
|
+
/** What to tell a game that has no owner address yet. */
|
|
12
|
+
noAddressHelp?: string;
|
|
13
|
+
/** Where the game's default switches are written ("game.config.json"). */
|
|
14
|
+
defaultsFile?: string;
|
|
15
|
+
/** A picture of a look for the Shelf (default: a flat one). */
|
|
16
|
+
lookPicture?: (look: Look) => Element;
|
|
17
|
+
/** What the Shelf says when the status gives no repository. */
|
|
18
|
+
repoHelp?: string;
|
|
19
|
+
/** What the Sign-ins card says about what the game keeps and for how long ("30 days"). Default: only the time, the rough country and a short browser name. */
|
|
20
|
+
loginsNote?: string;
|
|
21
|
+
/** The `fetch` to use (default: the page's). */
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
};
|
|
24
|
+
export type MountedAdmin = {
|
|
25
|
+
/** Reads everything again and redraws. */
|
|
26
|
+
reload(): Promise<void>;
|
|
27
|
+
/** Resolves when the first read has been drawn (a test waits for it). */
|
|
28
|
+
ready: Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
export declare function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdmin;
|