@nano133/nanoterra 0.10.0 → 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 +30 -0
- package/README.md +110 -7
- package/ai/SPEC.md +30 -2
- package/dist/admin/conformance.d.ts +7 -0
- package/dist/admin/conformance.js +17 -18
- 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/conformance.ts +23 -18
- 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,179 @@
|
|
|
1
|
+
// The admin sign-in's store for a game on Firestore (the Admin SDK). The package has no Firebase dependency: the adapter uses the small interface
|
|
2
|
+
// below, which the Admin SDK's `Firestore` fits. It reads and writes ONLY its three collections, and only the admin's documents (scope "admin") of the
|
|
3
|
+
// first two: the player sign-in's Firestore adapter keeps its own (scope "play") in the same collections.
|
|
4
|
+
//
|
|
5
|
+
// import { createFirestoreAdminSignin } from "@nano133/nanoterra/admin-signin/firestore";
|
|
6
|
+
// const store = createFirestoreAdminSignin(getFirestore());
|
|
7
|
+
//
|
|
8
|
+
// The data model (the three collection names can be changed):
|
|
9
|
+
// signin_requests/{key} { id, way, starter, status, expiresAt, nonce?, issuedAt?, label?, account?, delivered, scope: "admin", deleteAt }
|
|
10
|
+
// sessions/{idHash} { address, createdAt, expiresAt, via, scope: "admin", deleteAt }
|
|
11
|
+
// admin_logins/{id} { at, country, browser, deleteAt }
|
|
12
|
+
// Times are ms numbers; `deleteAt` is a Date (a Timestamp in Firestore): set a TTL policy on `deleteAt` for each collection. `{key}` is the request's id;
|
|
13
|
+
// `{idHash}` the SHA-256 of "admin:" and the session's token. Every change that reads first is a transaction. Indexes: Firestore's own single-field
|
|
14
|
+
// indexes are enough (`id`, `scope`, `expiresAt`, `at`).
|
|
15
|
+
//
|
|
16
|
+
// The admin store for Firestore (`createFirestoreStore`) has no sign-in list and no "Sign out everywhere": give it this sign-in's,
|
|
17
|
+
// `store.recentLogins = signin.recentLogins` and `store.signOutEverywhere = signin.signOutEverywhere`.
|
|
18
|
+
import { ADMIN_LOGIN_KEEP_MS, ADMIN_WAYS } from "../types.js";
|
|
19
|
+
const DAY = 86_400_000;
|
|
20
|
+
/** At most this many documents go in one batch (Firestore's limit is 500). */
|
|
21
|
+
const BATCH = 400;
|
|
22
|
+
const text = (v) => (typeof v === "string" ? v : undefined);
|
|
23
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
24
|
+
const hex = (bytes) => Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (x) => x.toString(16).padStart(2, "0")).join("");
|
|
25
|
+
function requestOf(d, key, way) {
|
|
26
|
+
if (!d || d.scope !== "admin" || d.way !== way || typeof d.starter !== "string" || num(d.expiresAt) === undefined)
|
|
27
|
+
return null;
|
|
28
|
+
const nonce = text(d.nonce);
|
|
29
|
+
const issuedAt = num(d.issuedAt);
|
|
30
|
+
const label = text(d.label);
|
|
31
|
+
const account = text(d.account);
|
|
32
|
+
return {
|
|
33
|
+
id: text(d.id) ?? key,
|
|
34
|
+
starter: d.starter,
|
|
35
|
+
status: d.status === "signedin" ? "signedin" : "waiting",
|
|
36
|
+
expiresAt: num(d.expiresAt),
|
|
37
|
+
...(label !== undefined ? { label } : {}),
|
|
38
|
+
...(nonce !== undefined ? { nonce } : {}),
|
|
39
|
+
...(issuedAt !== undefined ? { issuedAt } : {}),
|
|
40
|
+
...(account !== undefined ? { account } : {}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function createFirestoreAdminSignin(db, collections = {}) {
|
|
44
|
+
const requestsCol = () => db.collection(collections.requests ?? "signin_requests");
|
|
45
|
+
const sessionsCol = () => db.collection(collections.sessions ?? "sessions");
|
|
46
|
+
const loginsCol = () => db.collection(collections.logins ?? "admin_logins");
|
|
47
|
+
const sessionData = (s) => ({ address: s.address, createdAt: s.createdAt, expiresAt: s.expiresAt, via: s.via, scope: "admin", deleteAt: new Date(s.expiresAt) });
|
|
48
|
+
const stores = new Map();
|
|
49
|
+
for (const way of ADMIN_WAYS) {
|
|
50
|
+
stores.set(way, {
|
|
51
|
+
async create(key, r) {
|
|
52
|
+
const ref = requestsCol().doc(key);
|
|
53
|
+
await db.runTransaction(async (tx) => {
|
|
54
|
+
if ((await tx.get(ref)).exists)
|
|
55
|
+
throw new Error("a request with this key exists");
|
|
56
|
+
tx.set(ref, {
|
|
57
|
+
id: r.id,
|
|
58
|
+
way,
|
|
59
|
+
starter: r.starter,
|
|
60
|
+
status: "waiting",
|
|
61
|
+
expiresAt: r.expiresAt,
|
|
62
|
+
...(r.nonce !== undefined ? { nonce: r.nonce } : {}),
|
|
63
|
+
...(r.issuedAt !== undefined ? { issuedAt: r.issuedAt } : {}),
|
|
64
|
+
...(r.label !== undefined ? { label: r.label } : {}),
|
|
65
|
+
delivered: false,
|
|
66
|
+
scope: "admin",
|
|
67
|
+
deleteAt: new Date(r.expiresAt + DAY),
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
async get(key) {
|
|
72
|
+
return requestOf((await requestsCol().doc(key).get()).data(), key, way);
|
|
73
|
+
},
|
|
74
|
+
async find(id) {
|
|
75
|
+
for (const d of (await requestsCol().where("id", "==", id).limit(10).get()).docs) {
|
|
76
|
+
const request = requestOf(d.data(), d.id, way);
|
|
77
|
+
if (request)
|
|
78
|
+
return { key: d.id, request };
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
},
|
|
82
|
+
async finish(key, account) {
|
|
83
|
+
const ref = requestsCol().doc(key);
|
|
84
|
+
return db.runTransaction(async (tx) => {
|
|
85
|
+
const cur = requestOf((await tx.get(ref)).data(), key, way);
|
|
86
|
+
if (!cur || cur.status !== "waiting")
|
|
87
|
+
return false;
|
|
88
|
+
tx.set(ref, { status: "signedin", account }, { merge: true });
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async function deleteAll(docs) {
|
|
95
|
+
for (let i = 0; i < docs.length; i += BATCH) {
|
|
96
|
+
const b = db.batch();
|
|
97
|
+
for (const d of docs.slice(i, i + BATCH))
|
|
98
|
+
b.delete(d.ref);
|
|
99
|
+
await b.commit();
|
|
100
|
+
}
|
|
101
|
+
return docs.length;
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
requests: (way) => {
|
|
105
|
+
const s = stores.get(way);
|
|
106
|
+
if (!s)
|
|
107
|
+
throw new Error(`no such way: ${String(way)}`);
|
|
108
|
+
return s;
|
|
109
|
+
},
|
|
110
|
+
async deliver(way, key, session) {
|
|
111
|
+
const ref = requestsCol().doc(key);
|
|
112
|
+
const sref = sessionsCol().doc(session.idHash);
|
|
113
|
+
return db.runTransaction(async (tx) => {
|
|
114
|
+
const raw = (await tx.get(ref)).data();
|
|
115
|
+
const cur = requestOf(raw, key, way);
|
|
116
|
+
await tx.get(sref);
|
|
117
|
+
if (!cur || cur.status !== "signedin" || raw?.delivered === true)
|
|
118
|
+
return false;
|
|
119
|
+
tx.set(ref, { delivered: true }, { merge: true });
|
|
120
|
+
tx.set(sref, sessionData(session));
|
|
121
|
+
return true;
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
async session(idHash, now) {
|
|
125
|
+
const d = (await sessionsCol().doc(idHash).get()).data();
|
|
126
|
+
if (!d || d.scope !== "admin" || typeof d.address !== "string")
|
|
127
|
+
return null;
|
|
128
|
+
const expiresAt = num(d.expiresAt);
|
|
129
|
+
if (expiresAt === undefined || expiresAt <= now)
|
|
130
|
+
return null;
|
|
131
|
+
return { idHash, address: d.address, createdAt: num(d.createdAt) ?? 0, expiresAt, via: String(d.via ?? "") };
|
|
132
|
+
},
|
|
133
|
+
async endSession(idHash) {
|
|
134
|
+
const ref = sessionsCol().doc(idHash);
|
|
135
|
+
await db.runTransaction(async (tx) => {
|
|
136
|
+
const d = (await tx.get(ref)).data();
|
|
137
|
+
if (d && d.scope === "admin")
|
|
138
|
+
tx.delete(ref);
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
async signOutEverywhere() {
|
|
142
|
+
// The requests first, then the sessions are read: a delivery that ran before the requests were ended wrote its session already, and it goes too; a
|
|
143
|
+
// delivery after it finds its request delivered (its transaction runs again on the changed document) and writes none.
|
|
144
|
+
const pending = (await requestsCol().where("scope", "==", "admin").get()).docs.filter((d) => d.data().delivered !== true);
|
|
145
|
+
for (let i = 0; i < pending.length; i += BATCH) {
|
|
146
|
+
const b = db.batch();
|
|
147
|
+
for (const d of pending.slice(i, i + BATCH))
|
|
148
|
+
b.set(d.ref, { delivered: true, expiresAt: 0 }, { merge: true });
|
|
149
|
+
await b.commit();
|
|
150
|
+
}
|
|
151
|
+
return deleteAll((await sessionsCol().where("scope", "==", "admin").get()).docs);
|
|
152
|
+
},
|
|
153
|
+
async recordLogin(login) {
|
|
154
|
+
const old = (await loginsCol().where("at", "<", login.at - ADMIN_LOGIN_KEEP_MS).limit(BATCH).get()).docs;
|
|
155
|
+
const b = db.batch();
|
|
156
|
+
b.set(loginsCol().doc(`${String(login.at).padStart(15, "0")}-${hex(8)}`), { at: login.at, country: login.country, browser: login.browser, deleteAt: new Date(login.at + ADMIN_LOGIN_KEEP_MS) });
|
|
157
|
+
for (const d of old)
|
|
158
|
+
b.delete(d.ref);
|
|
159
|
+
await b.commit();
|
|
160
|
+
},
|
|
161
|
+
async recentLogins(limit) {
|
|
162
|
+
const n = Math.min(Math.max(Math.floor(Number(limit)) || 0, 0), 50);
|
|
163
|
+
if (!n)
|
|
164
|
+
return [];
|
|
165
|
+
// Newest first; two at the same time come in the order of their ids (the time, then random letters).
|
|
166
|
+
const docs = (await loginsCol().orderBy("at", "desc").limit(n).get()).docs;
|
|
167
|
+
return docs.map((d) => {
|
|
168
|
+
const v = d.data();
|
|
169
|
+
return { at: num(v.at) ?? 0, country: text(v.country) ?? null, browser: text(v.browser) ?? "Unknown browser" };
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
async clean(now) {
|
|
173
|
+
const old = (await requestsCol().where("expiresAt", "<", now - 2 * 3_600_000).limit(BATCH).get()).docs.filter((d) => d.data().scope === "admin");
|
|
174
|
+
const ended = (await sessionsCol().where("expiresAt", "<", now).limit(BATCH).get()).docs.filter((d) => d.data().scope === "admin");
|
|
175
|
+
const logins = (await loginsCol().where("at", "<", now - ADMIN_LOGIN_KEEP_MS).limit(BATCH).get()).docs;
|
|
176
|
+
await deleteAll([...old, ...ended, ...logins]);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** "Chrome on macOS", "Safari on iOS", "Firefox", or "Unknown browser". At most 40 characters, only from the lists above. */
|
|
2
|
+
export declare function shortBrowser(userAgent: string | null | undefined): string;
|
|
3
|
+
/** The rough country of a request: Cloudflare's two-letter country header, or null (not there, "XX" for unknown, "T1" for Tor, or anything that is not two capital letters). */
|
|
4
|
+
export declare function countryOf(req: Request): string | null;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// What the admin's sign-in list keeps of a browser: a short name made from fixed lists ("Chrome on macOS") and the rough country. The game starter's
|
|
2
|
+
// src/worker/browser.ts, unchanged. It NEVER returns any of the browser's own text, so a hostile browser text cannot put anything in the list, and only
|
|
3
|
+
// this name is kept (never the browser text itself).
|
|
4
|
+
const BROWSERS = [
|
|
5
|
+
[/\bEdg(?:e|A|iOS)?\//, "Edge"],
|
|
6
|
+
[/\bOPR\/|\bOpera\b/, "Opera"],
|
|
7
|
+
[/\bSamsungBrowser\//, "Samsung Internet"],
|
|
8
|
+
[/\bFirefox\/|\bFxiOS\//, "Firefox"],
|
|
9
|
+
[/\bChrome\/|\bCriOS\//, "Chrome"],
|
|
10
|
+
[/\bSafari\//, "Safari"],
|
|
11
|
+
];
|
|
12
|
+
const SYSTEMS = [
|
|
13
|
+
[/\biPhone\b|\biPad\b|\biPod\b/, "iOS"],
|
|
14
|
+
[/\bAndroid\b/, "Android"],
|
|
15
|
+
[/\bCrOS\b/, "ChromeOS"],
|
|
16
|
+
[/\bWindows\b/, "Windows"],
|
|
17
|
+
[/\bMac OS X\b|\bMacintosh\b/, "macOS"],
|
|
18
|
+
[/\bLinux\b|\bX11\b/, "Linux"],
|
|
19
|
+
];
|
|
20
|
+
/** "Chrome on macOS", "Safari on iOS", "Firefox", or "Unknown browser". At most 40 characters, only from the lists above. */
|
|
21
|
+
export function shortBrowser(userAgent) {
|
|
22
|
+
if (typeof userAgent !== "string" || !userAgent)
|
|
23
|
+
return "Unknown browser";
|
|
24
|
+
// Only the start of the text is read: a long text cannot make the matching slow.
|
|
25
|
+
const ua = userAgent.slice(0, 400);
|
|
26
|
+
const browser = BROWSERS.find(([re]) => re.test(ua))?.[1];
|
|
27
|
+
const system = SYSTEMS.find(([re]) => re.test(ua))?.[1];
|
|
28
|
+
if (!browser && !system)
|
|
29
|
+
return "Unknown browser";
|
|
30
|
+
return `${browser ?? "Browser"}${system ? ` on ${system}` : ""}`;
|
|
31
|
+
}
|
|
32
|
+
/** The rough country of a request: Cloudflare's two-letter country header, or null (not there, "XX" for unknown, "T1" for Tor, or anything that is not two capital letters). */
|
|
33
|
+
export function countryOf(req) {
|
|
34
|
+
const c = req.headers.get("CF-IPCountry");
|
|
35
|
+
return typeof c === "string" && /^[A-Z]{2}$/.test(c) && c !== "XX" && c !== "T1" ? c : null;
|
|
36
|
+
}
|
|
@@ -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;
|