@nano133/nanoterra 0.10.1 → 0.11.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 +20 -0
- package/README.md +102 -5
- package/ai/SPEC.md +30 -2
- package/dist/admin-signin/adapters/d1.d.ts +10 -0
- package/dist/admin-signin/adapters/d1.js +106 -0
- package/dist/admin-signin/adapters/firestore.d.ts +49 -0
- package/dist/admin-signin/adapters/firestore.js +179 -0
- package/dist/admin-signin/browser.d.ts +4 -0
- package/dist/admin-signin/browser.js +36 -0
- package/dist/admin-signin/conformance.d.ts +11 -0
- package/dist/admin-signin/conformance.js +190 -0
- package/dist/admin-signin/handler.d.ts +62 -0
- package/dist/admin-signin/handler.js +360 -0
- package/dist/admin-signin/index.d.ts +8 -0
- package/dist/admin-signin/index.js +15 -0
- package/dist/admin-signin/memory.d.ts +13 -0
- package/dist/admin-signin/memory.js +101 -0
- package/dist/admin-signin/panel/index.d.ts +3 -0
- package/dist/admin-signin/panel/index.js +5 -0
- package/dist/admin-signin/panel/view.d.ts +40 -0
- package/dist/admin-signin/panel/view.js +182 -0
- package/dist/admin-signin/types.d.ts +105 -0
- package/dist/admin-signin/types.js +42 -0
- package/package.json +9 -2
- package/src/admin-signin/adapters/d1.sql +45 -0
- package/src/admin-signin/adapters/d1.ts +117 -0
- package/src/admin-signin/adapters/firestore.ts +207 -0
- package/src/admin-signin/browser.ts +38 -0
- package/src/admin-signin/conformance.ts +212 -0
- package/src/admin-signin/handler.ts +418 -0
- package/src/admin-signin/index.ts +29 -0
- package/src/admin-signin/memory.ts +100 -0
- package/src/admin-signin/panel/index.ts +7 -0
- package/src/admin-signin/panel/view.ts +217 -0
- package/src/admin-signin/types.ts +95 -0
- package/src/player-signin/panel/style.css +7 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AdminSigninStore } from "./types.js";
|
|
2
|
+
export type AdminSigninHarness = {
|
|
3
|
+
store: AdminSigninStore;
|
|
4
|
+
/** Every row the store keeps (requests, sessions, the sign-in list) as one text. */
|
|
5
|
+
dump(): Promise<string>;
|
|
6
|
+
};
|
|
7
|
+
export type AdminSigninConformanceCase = {
|
|
8
|
+
name: string;
|
|
9
|
+
run: () => Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export declare function adminSigninStoreConformance(make: () => Promise<AdminSigninHarness>): AdminSigninConformanceCase[];
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// The conformance suite of the admin sign-in's store: a game that writes its own store (another database) runs it, and so proves that its store keeps
|
|
2
|
+
// every rule the package's adapters keep. The package runs it on the memory store, D1 (on SQLite) and Firestore (a fake).
|
|
3
|
+
//
|
|
4
|
+
// import { adminSigninStoreConformance } from "@nano133/nanoterra/admin-signin/conformance";
|
|
5
|
+
// for (const c of adminSigninStoreConformance(async () => ({ store: myStore(), dump: () => allMyRowsAsText() }))) it(c.name, c.run);
|
|
6
|
+
//
|
|
7
|
+
// Each case gets a NEW, empty store from `make`. The rules: a request is finished once and delivered once, and a delivery writes its session in the same
|
|
8
|
+
// step; the two wallet apps stay apart; a session lasts until its time; "Sign out everywhere" ends every session and every request not delivered; the
|
|
9
|
+
// sign-in list keeps three things for 30 days. (The whole chain, with the player's sign-in beside it, is `adminSigninConformance` in
|
|
10
|
+
// "@nano133/nanoterra/admin/conformance".)
|
|
11
|
+
import { ADMIN_LOGIN_KEEP_MS } from "./types.js";
|
|
12
|
+
function check(ok, message) {
|
|
13
|
+
if (!ok)
|
|
14
|
+
throw new Error(message);
|
|
15
|
+
}
|
|
16
|
+
/** An address the suite can find in any text (a store does not check addresses: the handler does). */
|
|
17
|
+
const who = (n) => `nano_3${n}${"x".repeat(59 - n.length)}`;
|
|
18
|
+
const OWNER = who("own");
|
|
19
|
+
const hex = (n, len = 64) => n.toString(16).padStart(len, "0");
|
|
20
|
+
let serial = 0;
|
|
21
|
+
/** A request as a wallet's start makes it: a fresh id each time. */
|
|
22
|
+
function request(at, extra = {}) {
|
|
23
|
+
serial += 1;
|
|
24
|
+
const id = hex(Date.now() * 1000 + serial, 32).slice(-32);
|
|
25
|
+
return { key: id, r: { id, starter: hex(serial + 7), status: "waiting", expiresAt: at + 2 * 60_000, nonce: hex(serial + 99), issuedAt: at, ...extra } };
|
|
26
|
+
}
|
|
27
|
+
const session = (at, n, via = "wallet") => ({ idHash: hex(1_000_000 + n + serial++), address: OWNER, createdAt: at, expiresAt: at + 86_400_000, via });
|
|
28
|
+
export function adminSigninStoreConformance(make) {
|
|
29
|
+
const cases = [];
|
|
30
|
+
const add = (name, run) => cases.push({ name, run: async () => run(await make(), Date.now()) });
|
|
31
|
+
add("a request reads back as it was made, by its key and by its browser id, and only for its own wallet app", async ({ store }, now) => {
|
|
32
|
+
const { key, r } = request(now, { label: "ADMIN of game.test sign-in ABCD-EFGH from US" });
|
|
33
|
+
await store.requests("nautilus").create(key, r);
|
|
34
|
+
const back = await store.requests("nautilus").get(key);
|
|
35
|
+
check(back !== null && back.id === r.id && back.starter === r.starter && back.status === "waiting" && back.expiresAt === r.expiresAt && back.label === r.label && back.nonce === r.nonce && back.issuedAt === r.issuedAt && back.account === undefined, `read back: ${JSON.stringify(back)}`);
|
|
36
|
+
const found = await store.requests("nautilus").find(r.id);
|
|
37
|
+
check(found?.key === key && found.request.id === r.id, `find: ${JSON.stringify(found)}`);
|
|
38
|
+
check((await store.requests("berry").get(key)) === null, "Berry Wallet does not see a Nautilus request");
|
|
39
|
+
check((await store.requests("berry").find(r.id)) === null, "Berry Wallet does not find a Nautilus request");
|
|
40
|
+
check((await store.requests("nautilus").get(hex(12345, 32))) === null, "an unknown key is null");
|
|
41
|
+
});
|
|
42
|
+
add("NanChat's request (when the game turns NanChat on) is kept under the hash of its nonce and found by the browser's id, for NanChat only", async ({ store }, now) => {
|
|
43
|
+
const { r } = request(now);
|
|
44
|
+
const key = hex(424242);
|
|
45
|
+
await store.requests("nanchat").create(key, { ...r, id: "AbCdEfGhIjKlMnOpQrStUvWx" });
|
|
46
|
+
check((await store.requests("nanchat").find("AbCdEfGhIjKlMnOpQrStUvWx"))?.key === key, "found by the browser's id");
|
|
47
|
+
check((await store.requests("berry").find("AbCdEfGhIjKlMnOpQrStUvWx")) === null, "Berry Wallet does not find a NanChat request");
|
|
48
|
+
});
|
|
49
|
+
add("a second request with the same key is refused", async ({ store }, now) => {
|
|
50
|
+
const { key, r } = request(now);
|
|
51
|
+
await store.requests("berry").create(key, r);
|
|
52
|
+
let threw = false;
|
|
53
|
+
try {
|
|
54
|
+
await store.requests("berry").create(key, { ...r, starter: hex(1) });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
threw = true;
|
|
58
|
+
}
|
|
59
|
+
check(threw, "the second create throws");
|
|
60
|
+
check((await store.requests("berry").get(key))?.starter === r.starter, "the first request is unchanged");
|
|
61
|
+
});
|
|
62
|
+
add("finish signs a waiting request in once, with its account; a second finish, one on an unknown key, or one by the other wallet app is false", async ({ store }, now) => {
|
|
63
|
+
const { key, r } = request(now);
|
|
64
|
+
await store.requests("berry").create(key, r);
|
|
65
|
+
check(await store.requests("berry").finish(key, OWNER), "the first finish");
|
|
66
|
+
check(!(await store.requests("berry").finish(key, who("bob"))), "a second finish is false");
|
|
67
|
+
const back = await store.requests("berry").get(key);
|
|
68
|
+
check(back?.status === "signedin" && back.account === OWNER, `the account stays the first: ${JSON.stringify(back)}`);
|
|
69
|
+
check(!(await store.requests("berry").finish(hex(777, 32), OWNER)), "an unknown key");
|
|
70
|
+
check(!(await store.requests("nautilus").finish(key, OWNER)), "the other wallet app cannot finish it");
|
|
71
|
+
});
|
|
72
|
+
add("two finishes at the same moment: exactly one is true", async ({ store }, now) => {
|
|
73
|
+
const { key, r } = request(now);
|
|
74
|
+
await store.requests("berry").create(key, r);
|
|
75
|
+
const both = await Promise.all([store.requests("berry").finish(key, OWNER), store.requests("berry").finish(key, who("bob"))]);
|
|
76
|
+
check(both.filter(Boolean).length === 1, `results: ${JSON.stringify(both)}`);
|
|
77
|
+
});
|
|
78
|
+
add("deliver: false while waiting; then once, with its session; a second delivery writes no session", async ({ store }, now) => {
|
|
79
|
+
const { key, r } = request(now);
|
|
80
|
+
await store.requests("berry").create(key, r);
|
|
81
|
+
const early = session(now, 1);
|
|
82
|
+
check(!(await store.deliver("berry", key, early)), "a waiting request is not delivered");
|
|
83
|
+
check((await store.session(early.idHash, now)) === null, "and no session was written");
|
|
84
|
+
await store.requests("berry").finish(key, OWNER);
|
|
85
|
+
const s1 = session(now, 2);
|
|
86
|
+
check(await store.deliver("berry", key, s1), "the first delivery");
|
|
87
|
+
const read = await store.session(s1.idHash, now);
|
|
88
|
+
check(read?.address === OWNER && read.via === "wallet" && read.expiresAt === s1.expiresAt && read.createdAt === s1.createdAt && read.idHash === s1.idHash, `the session: ${JSON.stringify(read)}`);
|
|
89
|
+
const s2 = session(now, 3);
|
|
90
|
+
check(!(await store.deliver("berry", key, s2)), "a second delivery is false");
|
|
91
|
+
check((await store.session(s2.idHash, now)) === null, "and wrote no session");
|
|
92
|
+
check(!(await store.deliver("nautilus", key, session(now, 4))), "the other wallet app cannot deliver it");
|
|
93
|
+
check(!(await store.deliver("berry", hex(888, 32), session(now, 5))), "an unknown key");
|
|
94
|
+
});
|
|
95
|
+
add("two deliveries at the same moment: one is true, and one session is written", async ({ store, dump }, now) => {
|
|
96
|
+
const { key, r } = request(now);
|
|
97
|
+
await store.requests("nautilus").create(key, r);
|
|
98
|
+
await store.requests("nautilus").finish(key, OWNER);
|
|
99
|
+
const a = session(now, 10);
|
|
100
|
+
const b = session(now, 11);
|
|
101
|
+
const both = await Promise.all([store.deliver("nautilus", key, a), store.deliver("nautilus", key, b)]);
|
|
102
|
+
check(both.filter(Boolean).length === 1, `results: ${JSON.stringify(both)}`);
|
|
103
|
+
const live = [await store.session(a.idHash, now), await store.session(b.idHash, now)].filter(Boolean);
|
|
104
|
+
check(live.length === 1, `sessions written: ${live.length} (${(await dump()).slice(0, 300)})`);
|
|
105
|
+
});
|
|
106
|
+
add("a session reads while it lasts and not from its end, with the word it was written with; ending it removes it", async ({ store }, now) => {
|
|
107
|
+
for (const [i, via] of ["wallet", "ticket"].entries()) {
|
|
108
|
+
const { key, r } = request(now);
|
|
109
|
+
await store.requests("berry").create(key, r);
|
|
110
|
+
await store.requests("berry").finish(key, OWNER);
|
|
111
|
+
const s = session(now, 20 + i, via);
|
|
112
|
+
await store.deliver("berry", key, s);
|
|
113
|
+
const read = await store.session(s.idHash, now);
|
|
114
|
+
check(read?.address === OWNER && read.via === via, `read: ${JSON.stringify(read)} (a store returns the word it holds; the sign-in reads anything but "wallet" and "nanoterra" as "unknown")`);
|
|
115
|
+
check((await store.session(s.idHash, s.expiresAt)) === null, "not at its end");
|
|
116
|
+
check((await store.session(s.idHash, s.expiresAt + 1)) === null, "not after it");
|
|
117
|
+
await store.endSession(s.idHash);
|
|
118
|
+
check((await store.session(s.idHash, now)) === null, "ended");
|
|
119
|
+
await store.endSession(s.idHash);
|
|
120
|
+
}
|
|
121
|
+
check((await store.session(hex(999), now)) === null, "an unknown hash");
|
|
122
|
+
});
|
|
123
|
+
add("Sign out everywhere ends every admin session and says how many; a signed request that was not delivered, and a waiting one, make no login after it", async ({ store }, now) => {
|
|
124
|
+
const made = [];
|
|
125
|
+
for (let i = 0; i < 3; i++) {
|
|
126
|
+
const { key, r } = request(now);
|
|
127
|
+
await store.requests("berry").create(key, r);
|
|
128
|
+
await store.requests("berry").finish(key, OWNER);
|
|
129
|
+
const s = session(now, 30 + i);
|
|
130
|
+
await store.deliver("berry", key, s);
|
|
131
|
+
made.push(s);
|
|
132
|
+
}
|
|
133
|
+
const signed = request(now);
|
|
134
|
+
await store.requests("nautilus").create(signed.key, signed.r);
|
|
135
|
+
await store.requests("nautilus").finish(signed.key, OWNER);
|
|
136
|
+
const waiting = request(now);
|
|
137
|
+
await store.requests("berry").create(waiting.key, waiting.r);
|
|
138
|
+
check((await store.signOutEverywhere()) === 3, "three sessions went");
|
|
139
|
+
for (const s of made)
|
|
140
|
+
check((await store.session(s.idHash, now)) === null, "a session is left");
|
|
141
|
+
check(!(await store.deliver("nautilus", signed.key, session(now, 40))), "a signed request that was not delivered makes a login after Sign out everywhere");
|
|
142
|
+
const w = await store.requests("berry").get(waiting.key);
|
|
143
|
+
check(w === null || w.expiresAt <= now, `a waiting request must have ended (its time over), so a wallet's answer is refused: ${JSON.stringify(w)}`);
|
|
144
|
+
// Even if a wallet's answer were taken, the request is not delivered again.
|
|
145
|
+
await store.requests("berry").finish(waiting.key, OWNER);
|
|
146
|
+
check(!(await store.deliver("berry", waiting.key, session(now, 41))), "a request that was waiting at Sign out everywhere makes a login afterwards");
|
|
147
|
+
check((await store.signOutEverywhere()) === 0, "a second call ends nothing and says 0");
|
|
148
|
+
});
|
|
149
|
+
add("the sign-in list: newest first, at most the limit, only the time, the country and the browser; a row older than 30 days goes when the next is written", async ({ store, dump }, now) => {
|
|
150
|
+
check((await store.recentLogins(10)).length === 0, "a new store lists no sign-ins");
|
|
151
|
+
await store.recordLogin({ at: now - ADMIN_LOGIN_KEEP_MS - 60_000, country: "FR", browser: "Old" });
|
|
152
|
+
await store.recordLogin({ at: now - ADMIN_LOGIN_KEEP_MS + 86_400_000, country: "FR", browser: "Kept" });
|
|
153
|
+
for (let i = 0; i < 12; i++)
|
|
154
|
+
await store.recordLogin({ at: now - 1000 * (12 - i), country: i % 3 === 0 ? null : "US", browser: `Chrome on macOS ${i}` });
|
|
155
|
+
const got = await store.recentLogins(10);
|
|
156
|
+
check(got.length === 10, `expected 10 sign-ins, got ${got.length}`);
|
|
157
|
+
check(got.every((l, i, all) => i === 0 || all[i - 1].at >= l.at), "the sign-ins are not newest first");
|
|
158
|
+
check(got[0].at === now - 1000 && got[0].browser === "Chrome on macOS 11" && got[0].country === "US", `the first must be the newest: ${JSON.stringify(got[0])}`);
|
|
159
|
+
check(got.some((l) => l.country === null), "a sign-in with no country keeps null");
|
|
160
|
+
check(got.every((l) => Object.keys(l).sort().join() === "at,browser,country"), `a sign-in must carry only at, browser and country: ${JSON.stringify(got[0])}`);
|
|
161
|
+
check((await store.recentLogins(3)).length === 3, "the limit must hold");
|
|
162
|
+
check((await store.recentLogins(0)).length === 0, "a limit of 0 lists none");
|
|
163
|
+
const all = await store.recentLogins(50);
|
|
164
|
+
check(all.length === 13 && all.some((l) => l.browser === "Kept") && !all.some((l) => l.browser === "Old"), `the row of 31 days went, the row of 29 days stayed: ${all.map((l) => l.browser).join(", ")}`);
|
|
165
|
+
check(!(await dump()).includes("Old"), "the old row is gone from the store");
|
|
166
|
+
});
|
|
167
|
+
add("clean deletes requests 2 hours after they ended, ended sessions and sign-in rows past 30 days, and keeps the rest", async ({ store }, now) => {
|
|
168
|
+
const sessions = [];
|
|
169
|
+
for (const [i, at] of [now - 2 * 86_400_000, now].entries()) {
|
|
170
|
+
const { key, r } = request(at);
|
|
171
|
+
await store.requests("berry").create(key, r);
|
|
172
|
+
await store.requests("berry").finish(key, OWNER);
|
|
173
|
+
const s = session(at, 50 + i);
|
|
174
|
+
await store.deliver("berry", key, s);
|
|
175
|
+
sessions.push(s);
|
|
176
|
+
}
|
|
177
|
+
const old = request(now - 3 * 3_600_000);
|
|
178
|
+
const fresh = request(now);
|
|
179
|
+
await store.requests("berry").create(old.key, old.r);
|
|
180
|
+
await store.requests("berry").create(fresh.key, fresh.r);
|
|
181
|
+
await store.recordLogin({ at: now - ADMIN_LOGIN_KEEP_MS + 60_000, country: null, browser: "Edge on Windows" });
|
|
182
|
+
await store.clean(now + 120_000);
|
|
183
|
+
check((await store.session(sessions[1].idHash, now)) !== null, "the live session stays");
|
|
184
|
+
check((await store.session(sessions[0].idHash, now - 2 * 86_400_000)) === null, "the ended session went");
|
|
185
|
+
check((await store.requests("berry").get(old.key)) === null, "the old request went");
|
|
186
|
+
check((await store.requests("berry").get(fresh.key)) !== null, "the fresh request stays");
|
|
187
|
+
check((await store.recentLogins(10)).length === 0, "the sign-in row past 30 days went");
|
|
188
|
+
});
|
|
189
|
+
return cases;
|
|
190
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { AdminLogin } from "../admin/store.js";
|
|
2
|
+
import { type AdminSigninSession, type AdminSigninStore } from "./types.js";
|
|
3
|
+
/** The address that belongs to nobody (its key is all zeros): Nautilus names it while the game has no address of its own. */
|
|
4
|
+
export declare const EXAMPLE_ADDRESS = "nano_1111111111111111111111111111111111111111111111111111hifc8npp";
|
|
5
|
+
export type AdminSigninOptions = {
|
|
6
|
+
store: AdminSigninStore;
|
|
7
|
+
/**
|
|
8
|
+
* The game's own address (the starter's RECEIVE_ADDRESS: "nano_...", lowercase, its checksum checked), or null when none is set. Only this address makes an
|
|
9
|
+
* admin login: a signature by any other wallet is answered "not_owner" and the request stays unused. Nautilus also NAMES it in its request and in the
|
|
10
|
+
* text it signs (no key, no payment); with none, the example address.
|
|
11
|
+
*/
|
|
12
|
+
owner: string | null;
|
|
13
|
+
/** Where the routes are. Default "/api/admin-signin" (ADMIN_SIGNIN_BASE): keep it, so every page and helper finds the admin's sign-in at the same place. */
|
|
14
|
+
base?: string;
|
|
15
|
+
/** The admin page that Berry Wallet signs in to. Default "/admin". A path with no query: Berry refuses a query. */
|
|
16
|
+
page?: string;
|
|
17
|
+
/** The game's host exactly as people see it ("mygame.com"). Every signed text names it. Default: the host of each request. When it is set, a call to any other host is refused (403). */
|
|
18
|
+
domain?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Offer NanChat for the admin. Default false: a NanChat start is refused ("NanChat cannot open the admin yet. Use Berry Wallet or Nautilus."). NanChat's
|
|
21
|
+
* text must start "Login to <host>" and cannot name the ADMIN, which is why it is off: the owner who is asked to sign cannot tell an admin sign-in from a
|
|
22
|
+
* game sign-in. Turn it on only when the owner accepts that; give the panel the same `nanchat: true`.
|
|
23
|
+
*/
|
|
24
|
+
nanchat?: boolean;
|
|
25
|
+
/** The cookies' names. Default { session: "admin", starter: "starter-admin" } (the starter's). On https they get the __Host- prefix. */
|
|
26
|
+
cookies?: {
|
|
27
|
+
session?: string;
|
|
28
|
+
starter?: string;
|
|
29
|
+
};
|
|
30
|
+
/** The shared limiter of the starts: false means "too many". Default: a fixed window in this process's memory. */
|
|
31
|
+
limit?: (key: string, limit: number, windowMs: number, now: number) => boolean | Promise<boolean>;
|
|
32
|
+
/** The visitor's key for the limit of the starts: a keyed hash of the IP address, never the address. Default: the CF-Connecting-IP header as it is. */
|
|
33
|
+
visitor?: (request: Request) => Promise<string>;
|
|
34
|
+
/** The person check (Turnstile) of a start: true when `token` passes. No hook: no check. */
|
|
35
|
+
personCheck?: (token: unknown, request: Request) => Promise<boolean>;
|
|
36
|
+
/** The time now in ms. Default Date.now. */
|
|
37
|
+
now?: () => number;
|
|
38
|
+
/** Told about a fault (a store). The visitor gets a plain 500 and never sees it. */
|
|
39
|
+
onError?: (e: unknown) => void;
|
|
40
|
+
};
|
|
41
|
+
export type AdminSignin = {
|
|
42
|
+
base: string;
|
|
43
|
+
/** Answers a route of the admin's sign-in, or null when the request is not one. */
|
|
44
|
+
handle(request: Request): Promise<Response | null>;
|
|
45
|
+
/** The ADMIN login of this request (the admin cookie only), or null: give it to `createAdminHandler` as its `session`. Never a player's login. */
|
|
46
|
+
session(request: Request, now?: number): Promise<AdminSigninSession | null>;
|
|
47
|
+
/** Ends this request's admin login only and returns the Set-Cookie value that clears the cookie (the game's own login, another person's perhaps, stays). */
|
|
48
|
+
signOut(request: Request): Promise<string>;
|
|
49
|
+
/** "Sign out everywhere": every admin login and every admin request not yet delivered. Give it to the admin store (`signOutEverywhere`) when the store has none. */
|
|
50
|
+
signOutEverywhere(): Promise<number>;
|
|
51
|
+
/** The last admin sign-ins, newest first. Give it to the admin store (`recentLogins`) when the store has none. */
|
|
52
|
+
recentLogins(limit: number): Promise<AdminLogin[]>;
|
|
53
|
+
/** The clean-up (a cron): old admin requests, ended admin sessions, sign-in rows past 30 days. */
|
|
54
|
+
clean(now?: number): Promise<void>;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* What the database keeps of an admin session or of an admin request's starter: the hash of the value with a prefix. So the SERVER (not only the cookie's
|
|
58
|
+
* name) keeps the two scopes apart: a token made for the game (the player sign-in keeps its plain hash) does not open /admin even if it is put in the admin
|
|
59
|
+
* cookie, and a starter value copied from the game's cookie does not finish an admin request.
|
|
60
|
+
*/
|
|
61
|
+
export declare const adminKey: (value: string) => Promise<string>;
|
|
62
|
+
export declare function createAdminSignin(options: AdminSigninOptions): AdminSignin;
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// The routes of the ADMIN's sign-in, on the Web `Request` and `Response` types (a Worker, Firebase Functions, Node). The game starter's admin sign-in
|
|
2
|
+
// (its src/worker/signin.ts), moved into the package with every check and every signed text as the starter has them; only the paths are new, and the same
|
|
3
|
+
// in every game:
|
|
4
|
+
//
|
|
5
|
+
// POST {base}/start the browser starts an admin request: { way, turnstile? } → { way, id, link, walletLink?, code?, expiresAt } and the starter cookie
|
|
6
|
+
// GET {base}/nano/:id Berry Wallet reads the request (no cookie, no Origin)
|
|
7
|
+
// POST {base}/nano/:id Berry Wallet posts its signature: 204 with no body when accepted, 401 and the words when refused
|
|
8
|
+
// POST {base}/nautilus?id= Nautilus posts its signature: always 200 (Nautilus shows only a 200's words)
|
|
9
|
+
// POST {base}/nanchat NanChat's relay server posts the signature (only with `nanchat: true`): always 200, a refusal starts "NOT signed in"
|
|
10
|
+
// POST {base}/check the browser asks every 2 seconds: { id, way } → { status: "waiting" | "expired" | "elsewhere" | "used" | "not_owner" | "signedin", address? }
|
|
11
|
+
// POST {base}/signout ends this browser's admin login only: { ok: true }
|
|
12
|
+
//
|
|
13
|
+
// `base` is "/api/admin-signin" (ADMIN_SIGNIN_BASE). Berry Wallet and Nautilus sign a text that names the ADMIN: Berry signs the page `${origin}/admin`
|
|
14
|
+
// with NO query (Berry refused "/admin?from=US" with "Sign-in requires a public HTTPS website", device test 2026-10-27), Nautilus signs the label "ADMIN of
|
|
15
|
+
// <host> sign-in ABCD-EFGH from <country>" and shows "Sign in to the ADMIN of <host>. Started <time> UTC from <country>. If you did not start this, do not
|
|
16
|
+
// sign." NanChat is OFF by default and refused with the starter's words: its text must start "Login to <host>" and CANNOT name the ADMIN, so the owner
|
|
17
|
+
// who is asked to sign sees what looks like any sign-in to the game. A game may turn it on (`nanchat: true`); it then signs "Login to <host>" as a
|
|
18
|
+
// player's NanChat sign-in does, and the admin's own checks (the starter cookie, the owner, once) are the same. A request lives 2 minutes, is
|
|
19
|
+
// delivered for one more minute, and works once.
|
|
20
|
+
//
|
|
21
|
+
// Two logins, two cookies: the game's (`session`, the player sign-in's) and the admin's (`admin`; `__Host-admin` on https, HttpOnly, SameSite=Lax, one
|
|
22
|
+
// day), so the game and /admin can have different people signed in at once. The database keeps `adminKey(token)`, the SHA-256 of "admin:" and the token,
|
|
23
|
+
// and a player's session keeps the plain hash: a player's token put in the admin cookie opens nothing, and a starter value copied from the player's cookie
|
|
24
|
+
// finishes no admin request. Only the game's own address (`owner`) makes an admin login. DO NOT CHANGE the checks in this file: they are what makes the
|
|
25
|
+
// admin page the creator's.
|
|
26
|
+
import { answerNanchat, answerNanoSignin, answerNautilus, checkNanchat, checkNanoSignin, checkNautilus, nanchatNonce, nanchatReply, NANO_SIGNIN_ACCEPTED, nanoSigninRefusal, nautilusReply, readNanoSignin, startNanchat, startNanoSignin, startNautilus, validAddress, WalletRefused, } from "@nano133/signin";
|
|
27
|
+
import { ADMIN_SESSION_MS } from "../admin/guard.js";
|
|
28
|
+
import { fail, json, memoryLimiter, readLimited, sameOrigin } from "../admin/http.js";
|
|
29
|
+
import { countryOf, shortBrowser } from "./browser.js";
|
|
30
|
+
import { ADMIN_DELIVER_MS, ADMIN_PAGE, ADMIN_REQUEST_MS, ADMIN_SIGNIN_BASE, ADMIN_SIGNIN_LIMITS, ADMIN_STARTER_MAX_AGE_S, ADMIN_WAYS, NANCHAT_REFUSAL, TOKEN_PATTERN, } from "./types.js";
|
|
31
|
+
/** The address that belongs to nobody (its key is all zeros): Nautilus names it while the game has no address of its own. */
|
|
32
|
+
export const EXAMPLE_ADDRESS = "nano_1111111111111111111111111111111111111111111111111111hifc8npp";
|
|
33
|
+
const hex = (b) => Array.from(b instanceof Uint8Array ? b : new Uint8Array(b), (x) => x.toString(16).padStart(2, "0")).join("");
|
|
34
|
+
const sha256Hex = async (text) => hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
|
|
35
|
+
const randomHex = (bytes) => hex(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
36
|
+
/**
|
|
37
|
+
* What the database keeps of an admin session or of an admin request's starter: the hash of the value with a prefix. So the SERVER (not only the cookie's
|
|
38
|
+
* name) keeps the two scopes apart: a token made for the game (the player sign-in keeps its plain hash) does not open /admin even if it is put in the admin
|
|
39
|
+
* cookie, and a starter value copied from the game's cookie does not finish an admin request.
|
|
40
|
+
*/
|
|
41
|
+
export const adminKey = (value) => sha256Hex(`admin:${value}`);
|
|
42
|
+
/** Only a space or a tab around a cookie's name or value is dropped (JS trim() also drops Unicode spaces, which would let another name pass for ours). */
|
|
43
|
+
const trimAscii = (s) => s.replace(/^[ \t]+|[ \t]+$/g, "");
|
|
44
|
+
function cookieValue(req, name) {
|
|
45
|
+
for (const part of (req.headers.get("Cookie") ?? "").split(";")) {
|
|
46
|
+
const i = part.indexOf("=");
|
|
47
|
+
if (i > 0 && trimAscii(part.slice(0, i)) === name) {
|
|
48
|
+
try {
|
|
49
|
+
return decodeURIComponent(trimAscii(part.slice(i + 1)));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Not valid percent-coding: not ours.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const isHttps = (req) => new URL(req.url).protocol === "https:";
|
|
59
|
+
/** A cookie for this site only: on https it gets the __Host- prefix and Secure (the starter's exact form). */
|
|
60
|
+
function cookieHeader(req, name, value, maxAgeSec) {
|
|
61
|
+
const https = isHttps(req);
|
|
62
|
+
return `${https ? "__Host-" : ""}${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAgeSec}; HttpOnly; SameSite=Lax${https ? "; Secure" : ""}`;
|
|
63
|
+
}
|
|
64
|
+
/** A cookie's value: on https only the `__Host-` name (a sibling subdomain can set the plain one; it cannot set a `__Host-` one). */
|
|
65
|
+
const readCookie = (req, name) => cookieValue(req, isHttps(req) ? `__Host-${name}` : name);
|
|
66
|
+
/** The limiters live at module level: a Worker makes the sign-in for each request, and a limiter made inside it would forget every call. */
|
|
67
|
+
const fallbackLimit = memoryLimiter();
|
|
68
|
+
const memory = memoryLimiter();
|
|
69
|
+
export function createAdminSignin(options) {
|
|
70
|
+
const base = options.base ?? ADMIN_SIGNIN_BASE;
|
|
71
|
+
if (!base.startsWith("/") || base.length < 2 || base.endsWith("/"))
|
|
72
|
+
throw new Error('a path must start with "/" and not end with "/", like "/api/admin-signin"');
|
|
73
|
+
const page = options.page ?? ADMIN_PAGE;
|
|
74
|
+
if (!/^\/[A-Za-z0-9._~\-/]*$/.test(page))
|
|
75
|
+
throw new Error('the admin page is a path with no query, like "/admin"');
|
|
76
|
+
const store = options.store;
|
|
77
|
+
const sessionName = options.cookies?.session ?? "admin";
|
|
78
|
+
const starterName = options.cookies?.starter ?? "starter-admin";
|
|
79
|
+
for (const n of [sessionName, starterName])
|
|
80
|
+
if (!/^[A-Za-z0-9_-]{1,40}$/.test(n))
|
|
81
|
+
throw new Error("a cookie's name is letters, digits, _ and - only");
|
|
82
|
+
if (sessionName === starterName)
|
|
83
|
+
throw new Error("the session cookie and the starter cookie need two names");
|
|
84
|
+
const owner = options.owner;
|
|
85
|
+
const now = options.now ?? (() => Date.now());
|
|
86
|
+
const limit = options.limit ?? fallbackLimit;
|
|
87
|
+
const siteAddress = owner && validAddress(owner) ? owner : EXAMPLE_ADDRESS;
|
|
88
|
+
const nanchatOn = options.nanchat === true;
|
|
89
|
+
const report = (e) => {
|
|
90
|
+
try {
|
|
91
|
+
options.onError?.(e);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// nothing more can be done
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const ipOf = (req) => req.headers.get("CF-Connecting-IP") ?? "anyone";
|
|
98
|
+
const visitorOf = async (req) => (options.visitor ? await options.visitor(req) : ipOf(req));
|
|
99
|
+
const person = async (token, req) => (options.personCheck ? options.personCheck(token, req) : true);
|
|
100
|
+
/**
|
|
101
|
+
* The sign-in options of each wallet app for this request's site: the domain and page come from the address the owner opened (or `domain`). `admin` is
|
|
102
|
+
* given for a START: the request's life is 2 minutes, and Nautilus's label and line name the admin and the place the request started. Berry Wallet's text
|
|
103
|
+
* names the admin through its page, for every call (each request this sign-in holds is an admin request), so the wallet's GET and POST get the same text.
|
|
104
|
+
*/
|
|
105
|
+
function wallets(req, at, admin) {
|
|
106
|
+
const url = new URL(req.url);
|
|
107
|
+
const host = options.domain ?? url.host;
|
|
108
|
+
const origin = `${url.protocol}//${host}`;
|
|
109
|
+
const clock = { now: () => at };
|
|
110
|
+
const life = admin ? { ttlMs: ADMIN_REQUEST_MS } : {};
|
|
111
|
+
const started = new Date(at).toISOString().slice(11, 16);
|
|
112
|
+
const adminLine = `Sign in to the ADMIN of ${host}. Started ${started} UTC from ${admin?.country ?? "an unknown country"}. If you did not start this, do not sign.`;
|
|
113
|
+
return {
|
|
114
|
+
host,
|
|
115
|
+
berry: { domain: host, uri: `${origin}${page}`, store: store.requests("berry"), ...life, ...clock },
|
|
116
|
+
nautilus: {
|
|
117
|
+
site: host,
|
|
118
|
+
siteAddress,
|
|
119
|
+
store: store.requests("nautilus"),
|
|
120
|
+
...(admin ? { title: `ADMIN of ${host}`, ...(admin.country ? { note: `from ${admin.country}` } : {}), message: adminLine } : {}),
|
|
121
|
+
...life,
|
|
122
|
+
...clock,
|
|
123
|
+
},
|
|
124
|
+
// NanChat's text is "Login to <host> nonce: <nonce>": it has no place for the admin's name.
|
|
125
|
+
nanchat: { host, store: store.requests("nanchat"), ...life, ...clock },
|
|
126
|
+
nanchatUrl: `${origin}${base}/nanchat`,
|
|
127
|
+
requestUrl: (id) => `${origin}${base}/nano/${id}`,
|
|
128
|
+
nautilusUrl: (id) => `${origin}${base}/nautilus?id=${id}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** A JSON body of at most 8 KB, or null when it is not one readable object. */
|
|
132
|
+
async function body(req) {
|
|
133
|
+
const text = await readLimited(req, ADMIN_SIGNIN_LIMITS.bodyMax);
|
|
134
|
+
if (text === null)
|
|
135
|
+
return null;
|
|
136
|
+
if (!text)
|
|
137
|
+
return {};
|
|
138
|
+
try {
|
|
139
|
+
const v = JSON.parse(text);
|
|
140
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** The way a call names; Berry Wallet when it names none. Null for anything else. NanChat is a way even when it is off, so that it is refused with its own words. */
|
|
147
|
+
const wayOf = (v) => (v === undefined ? "berry" : (ADMIN_WAYS.find((w) => w === v) ?? null));
|
|
148
|
+
async function start(req, b, at) {
|
|
149
|
+
const way = wayOf(b.way);
|
|
150
|
+
if (!way)
|
|
151
|
+
return fail(400, "That wallet is not one of this game's.");
|
|
152
|
+
if (!(await limit(`signin|${await visitorOf(req)}`, ADMIN_SIGNIN_LIMITS.signin.limit, ADMIN_SIGNIN_LIMITS.signin.windowMs, at)))
|
|
153
|
+
return fail(429, "Too many sign-ins. Wait a few minutes.");
|
|
154
|
+
if (!(await person(b.turnstile, req)))
|
|
155
|
+
return fail(403, "The person check failed. Try again.");
|
|
156
|
+
const admin = { country: countryOf(req) };
|
|
157
|
+
// NanChat cannot name the admin in what it signs (its text must start "Login to <host>"): an admin request through it would look like any sign-in to
|
|
158
|
+
// the owner who is asked to sign. So it is refused unless the game turned it on.
|
|
159
|
+
if (way === "nanchat" && !nanchatOn)
|
|
160
|
+
return fail(400, NANCHAT_REFUSAL);
|
|
161
|
+
const starter = randomHex(32);
|
|
162
|
+
const hash = await adminKey(starter);
|
|
163
|
+
const w = wallets(req, at, admin);
|
|
164
|
+
let out;
|
|
165
|
+
if (way === "nautilus") {
|
|
166
|
+
const s = await startNautilus(w.nautilus, { starter: hash, answerUrl: w.nautilusUrl });
|
|
167
|
+
out = { way, id: s.id, link: s.link, code: s.code, expiresAt: s.expiresAt };
|
|
168
|
+
}
|
|
169
|
+
else if (way === "nanchat") {
|
|
170
|
+
const s = await startNanchat(w.nanchat, { starter: hash, answerUrl: w.nanchatUrl });
|
|
171
|
+
out = { way, id: s.id, link: s.link, expiresAt: s.expiresAt };
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
// `label` keeps where the request started ("admin:US"), as the starter wrote it: a starter that reads these rows sees an admin request it knows.
|
|
175
|
+
const s = await startNanoSignin(w.berry, { starter: hash, requestUrl: w.requestUrl, label: admin.country ? `admin:${admin.country}` : "admin" });
|
|
176
|
+
out = { way, id: s.id, link: s.link, walletLink: s.walletLink, expiresAt: s.expiresAt };
|
|
177
|
+
}
|
|
178
|
+
return json(out, 200, { "Set-Cookie": cookieHeader(req, starterName, starter, ADMIN_STARTER_MAX_AGE_S) });
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The browser's check of an admin request. When it is signed and this browser started it ("mine"), a NEW admin session is made, once per request, and
|
|
182
|
+
* its cookie is set.
|
|
183
|
+
*/
|
|
184
|
+
async function check(req, b, at) {
|
|
185
|
+
const way = wayOf(b.way);
|
|
186
|
+
if (!way)
|
|
187
|
+
return fail(400, "That wallet is not one of this game's.");
|
|
188
|
+
if (typeof b.id !== "string")
|
|
189
|
+
return fail(400, "no request id");
|
|
190
|
+
const cookie = readCookie(req, starterName);
|
|
191
|
+
// The starter cookie is what this sign-in made: 32 random bytes as 64 hex characters. A value of any other shape (one with a prefix of the other scope's hashing, for example) is no starter.
|
|
192
|
+
const starter = cookie && TOKEN_PATTERN.test(cookie) ? await adminKey(cookie) : undefined;
|
|
193
|
+
// While NanChat is off the admin starts no NanChat request: there is none to deliver.
|
|
194
|
+
if (way === "nanchat" && !nanchatOn)
|
|
195
|
+
return json({ status: "expired" });
|
|
196
|
+
// An admin request is delivered for a minute after it ends.
|
|
197
|
+
const o = { store: store.requests(way), now: () => at, deliverMs: ADMIN_DELIVER_MS };
|
|
198
|
+
const r = way === "nautilus" ? await checkNautilus(o, b.id, starter) : way === "nanchat" ? await checkNanchat(o, b.id, starter) : await checkNanoSignin(o, b.id, starter);
|
|
199
|
+
if (r.status !== "signedin")
|
|
200
|
+
return json({ status: r.status });
|
|
201
|
+
if (!r.mine || !r.account)
|
|
202
|
+
return json({ status: "elsewhere" });
|
|
203
|
+
// An admin login is for the game's own address only. A wallet that is not it makes no admin login (and does not end the owner's, which is in a cookie
|
|
204
|
+
// of its own), and the request stays unused.
|
|
205
|
+
if (r.account !== owner)
|
|
206
|
+
return json({ status: "not_owner", address: r.account });
|
|
207
|
+
// Deliver once: a second check of the same request makes no second session. The delivery and the session are ONE step of the store, and the session is
|
|
208
|
+
// written only when the delivery changed the request. So "Sign out everywhere" cannot run between them: it either ends the request first (nothing is
|
|
209
|
+
// delivered, no session) or deletes the session after.
|
|
210
|
+
const token = randomHex(32);
|
|
211
|
+
const record = { idHash: await adminKey(token), address: r.account, createdAt: at, expiresAt: at + ADMIN_SESSION_MS, via: "wallet" };
|
|
212
|
+
if (!(await store.deliver(way, r.key ?? b.id, record)))
|
|
213
|
+
return json({ status: "used" });
|
|
214
|
+
// The admin's sign-in list: the time, the rough country and a short browser name of THIS browser (the one that started the request and now gets the login). Nothing else.
|
|
215
|
+
await store.recordLogin({ at, country: countryOf(req), browser: shortBrowser(req.headers.get("User-Agent")) });
|
|
216
|
+
return json({ status: "signedin", address: r.account }, 200, { "Set-Cookie": cookieHeader(req, sessionName, token, ADMIN_SESSION_MS / 1000) });
|
|
217
|
+
}
|
|
218
|
+
/** Berry Wallet's GET and POST. They come from the wallet app, so there is no cookie and no Origin to check. */
|
|
219
|
+
async function berry(req, id, at) {
|
|
220
|
+
const w = wallets(req, at);
|
|
221
|
+
if (req.method === "GET") {
|
|
222
|
+
const request = await readNanoSignin(w.berry, id, w.requestUrl);
|
|
223
|
+
return request ? json(request) : json(nanoSigninRefusal("This sign-in expired."), 404);
|
|
224
|
+
}
|
|
225
|
+
const b = await body(req);
|
|
226
|
+
if (!b)
|
|
227
|
+
return json(nanoSigninRefusal("This answer is not readable."), 401);
|
|
228
|
+
try {
|
|
229
|
+
await answerNanoSignin(w.berry, id, b, w.requestUrl);
|
|
230
|
+
// Berry Wallet shows its success screen only for a 204 with no body.
|
|
231
|
+
return new Response(null, { status: NANO_SIGNIN_ACCEPTED, headers: { "Cache-Control": "no-store" } });
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
if (e instanceof WalletRefused)
|
|
235
|
+
return json(nanoSigninRefusal(e.message), 401);
|
|
236
|
+
throw e;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Nautilus's answer. It comes from the app: no cookie, no Origin. Nautilus shows the words only with HTTP 200. */
|
|
240
|
+
async function nautilus(req, at) {
|
|
241
|
+
const w = wallets(req, at);
|
|
242
|
+
const b = await body(req);
|
|
243
|
+
if (!b)
|
|
244
|
+
return json(nautilusReply(w.host, "That answer could not be read."));
|
|
245
|
+
try {
|
|
246
|
+
await answerNautilus(w.nautilus, new URL(req.url).searchParams.get("id") ?? "", b);
|
|
247
|
+
return json(nautilusReply(w.host));
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
if (e instanceof WalletRefused)
|
|
251
|
+
return json(nautilusReply(w.host, e.message));
|
|
252
|
+
throw e;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** NanChat's answer (only while it is on): its relay server posts every one, so the limits are totals and per nonce, never per visitor. Always 200: NanChat shows the words. */
|
|
256
|
+
async function nanchat(req, at) {
|
|
257
|
+
const w = wallets(req, at);
|
|
258
|
+
const refuse = (words) => json(nanchatReply(w.host, words));
|
|
259
|
+
if (!memory(`${base}|nanchat`, ADMIN_SIGNIN_LIMITS.nanchatRoute.limit, ADMIN_SIGNIN_LIMITS.nanchatRoute.windowMs, at))
|
|
260
|
+
return refuse("too many sign-ins at once. Wait a minute.");
|
|
261
|
+
const b = await body(req);
|
|
262
|
+
if (!b)
|
|
263
|
+
return refuse("that answer could not be read.");
|
|
264
|
+
const nonce = nanchatNonce(w.host, b.message);
|
|
265
|
+
if (nonce && typeof b.account === "string" && typeof b.signature === "string" && /^[0-9A-Fa-f]{128}$/.test(b.signature)) {
|
|
266
|
+
if (!(await limit("admin-nanchat|all", ADMIN_SIGNIN_LIMITS.nanchatAll.limit, ADMIN_SIGNIN_LIMITS.nanchatAll.windowMs, at)))
|
|
267
|
+
return refuse("too many sign-ins at once. Wait a few minutes.");
|
|
268
|
+
if (!(await limit(`admin-nanchat|${nonce}`, ADMIN_SIGNIN_LIMITS.nanchatNonce.limit, ADMIN_SIGNIN_LIMITS.nanchatNonce.windowMs, at)))
|
|
269
|
+
return refuse(`too many tries for this sign-in. Start again on ${w.host}.`);
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
await answerNanchat(w.nanchat, b);
|
|
273
|
+
return json(nanchatReply(w.host));
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
if (e instanceof WalletRefused)
|
|
277
|
+
return refuse(e.message);
|
|
278
|
+
throw e;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function sessionOf(req, at = now()) {
|
|
282
|
+
const token = readCookie(req, sessionName);
|
|
283
|
+
if (!token || !TOKEN_PATTERN.test(token))
|
|
284
|
+
return null;
|
|
285
|
+
// The scope is in the store's row (the adapter reads only admin rows) as well as in the hash: the two scopes stay apart in two ways.
|
|
286
|
+
const s = await store.session(await adminKey(token), at);
|
|
287
|
+
// Only the two words a login can carry are read; any other is "unknown", never a wallet login (a fail-open map would turn a stray value into a login that opens /admin).
|
|
288
|
+
return s ? { address: s.address, createdAt: s.createdAt, via: s.via === "wallet" || s.via === "nanoterra" ? s.via : "unknown" } : null;
|
|
289
|
+
}
|
|
290
|
+
async function signOut(req) {
|
|
291
|
+
const token = readCookie(req, sessionName);
|
|
292
|
+
if (token)
|
|
293
|
+
await store.endSession(await adminKey(token));
|
|
294
|
+
return cookieHeader(req, sessionName, "", 0);
|
|
295
|
+
}
|
|
296
|
+
async function handle(req) {
|
|
297
|
+
const url = new URL(req.url);
|
|
298
|
+
const path = url.pathname;
|
|
299
|
+
const berryId = path.startsWith(`${base}/nano/`) ? /^[0-9a-f]{32}$/.exec(path.slice(base.length + 6))?.[0] : undefined;
|
|
300
|
+
const mine = berryId !== undefined || [`${base}/start`, `${base}/check`, `${base}/signout`, `${base}/nautilus`].includes(path) || (nanchatOn && path === `${base}/nanchat`);
|
|
301
|
+
if (!mine)
|
|
302
|
+
return null;
|
|
303
|
+
try {
|
|
304
|
+
if (options.domain && url.host !== options.domain)
|
|
305
|
+
return fail(403, `This sign-in belongs to ${options.domain}.`);
|
|
306
|
+
const at = now();
|
|
307
|
+
const ip = ipOf(req);
|
|
308
|
+
// The wallet apps' routes: no cookie and no Origin (an app posts them, not a browser page).
|
|
309
|
+
if (berryId !== undefined) {
|
|
310
|
+
if (req.method !== "GET" && req.method !== "POST")
|
|
311
|
+
return fail(405, "Not allowed.", {}, { Allow: "GET, POST" });
|
|
312
|
+
if (!memory(`${base}|wallet|${ip}`, ADMIN_SIGNIN_LIMITS.wallet.limit, ADMIN_SIGNIN_LIMITS.wallet.windowMs, at))
|
|
313
|
+
return fail(429, "Too many requests.");
|
|
314
|
+
return await berry(req, berryId, at);
|
|
315
|
+
}
|
|
316
|
+
if (path === `${base}/nautilus`) {
|
|
317
|
+
if (req.method !== "POST")
|
|
318
|
+
return fail(405, "Not allowed.", {}, { Allow: "POST" });
|
|
319
|
+
// Nautilus shows the words of a refusal only with HTTP 200.
|
|
320
|
+
if (!memory(`${base}|wallet|${ip}`, ADMIN_SIGNIN_LIMITS.wallet.limit, ADMIN_SIGNIN_LIMITS.wallet.windowMs, at))
|
|
321
|
+
return json(nautilusReply(wallets(req, at).host, "Too many requests. Wait a minute."));
|
|
322
|
+
return await nautilus(req, at);
|
|
323
|
+
}
|
|
324
|
+
if (path === `${base}/nanchat`) {
|
|
325
|
+
if (req.method !== "POST")
|
|
326
|
+
return fail(405, "Not allowed.", {}, { Allow: "POST" });
|
|
327
|
+
return await nanchat(req, at);
|
|
328
|
+
}
|
|
329
|
+
// The browser's own calls: a POST from the game's own page.
|
|
330
|
+
if (req.method !== "POST")
|
|
331
|
+
return fail(405, "Not allowed.", {}, { Allow: "POST" });
|
|
332
|
+
if (path === `${base}/check` && !memory(`${base}|poll|${ip}`, ADMIN_SIGNIN_LIMITS.poll.limit, ADMIN_SIGNIN_LIMITS.poll.windowMs, at))
|
|
333
|
+
return fail(429, "Too many requests. Wait a minute.");
|
|
334
|
+
if (!sameOrigin(req))
|
|
335
|
+
return fail(403, "This call must come from the game's own page.");
|
|
336
|
+
// A sign-out reads no body.
|
|
337
|
+
if (path === `${base}/signout`)
|
|
338
|
+
return json({ ok: true }, 200, { "Set-Cookie": await signOut(req) });
|
|
339
|
+
const b = await body(req);
|
|
340
|
+
if (!b)
|
|
341
|
+
return fail(400, "That request is not readable.");
|
|
342
|
+
if (path === `${base}/check`)
|
|
343
|
+
return await check(req, b, at);
|
|
344
|
+
return await start(req, b, at);
|
|
345
|
+
}
|
|
346
|
+
catch (e) {
|
|
347
|
+
report(e);
|
|
348
|
+
return fail(500, "Something went wrong. Try again.");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
base,
|
|
353
|
+
handle,
|
|
354
|
+
session: sessionOf,
|
|
355
|
+
signOut,
|
|
356
|
+
signOutEverywhere: () => store.signOutEverywhere(),
|
|
357
|
+
recentLogins: (n) => store.recentLogins(n),
|
|
358
|
+
clean: (at = now()) => store.clean(at),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { EXAMPLE_ADDRESS, adminKey, createAdminSignin } from "./handler.js";
|
|
2
|
+
export type { AdminSignin, AdminSigninOptions } from "./handler.js";
|
|
3
|
+
export { createMemoryAdminSignin } from "./memory.js";
|
|
4
|
+
export type { MemoryAdminSignin } from "./memory.js";
|
|
5
|
+
export { countryOf, shortBrowser } from "./browser.js";
|
|
6
|
+
export { ADMIN_DELIVER_MS, ADMIN_LOGIN_KEEP_MS, ADMIN_PAGE, ADMIN_REQUEST_MS, ADMIN_SIGNIN_BASE, ADMIN_SIGNIN_LIMITS, ADMIN_STARTER_MAX_AGE_S, ADMIN_WAYS, NANCHAT_REFUSAL, } from "./types.js";
|
|
7
|
+
export type { AdminSessionRecord, AdminSigninSession, AdminSigninStore, AdminWay, RequestStoreLike, SigninRequest } from "./types.js";
|
|
8
|
+
export { ADMIN_SESSION_MS } from "../admin/guard.js";
|