@nano133/nanoterra 0.8.1 → 0.9.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 +52 -0
- package/README.md +342 -6
- package/ai/SPEC.md +65 -3
- package/dist/admin/panel/mount.d.ts +3 -0
- package/dist/admin/panel/mount.js +4 -2
- package/dist/player-signin/adapters/d1.d.ts +10 -0
- package/dist/player-signin/adapters/d1.js +108 -0
- package/dist/player-signin/adapters/firestore.d.ts +45 -0
- package/dist/player-signin/adapters/firestore.js +168 -0
- package/dist/player-signin/conformance.d.ts +11 -0
- package/dist/player-signin/conformance.js +160 -0
- package/dist/player-signin/handler.d.ts +64 -0
- package/dist/player-signin/handler.js +364 -0
- package/dist/player-signin/host.d.ts +4 -0
- package/dist/player-signin/host.js +10 -0
- package/dist/player-signin/index.d.ts +9 -0
- package/dist/player-signin/index.js +15 -0
- package/dist/player-signin/memory.d.ts +15 -0
- package/dist/player-signin/memory.js +101 -0
- package/dist/player-signin/panel/index.d.ts +9 -0
- package/dist/player-signin/panel/index.js +7 -0
- package/dist/player-signin/panel/memo.d.ts +12 -0
- package/dist/player-signin/panel/memo.js +41 -0
- package/dist/player-signin/panel/mount.d.ts +21 -0
- package/dist/player-signin/panel/mount.js +105 -0
- package/dist/player-signin/panel/view.d.ts +45 -0
- package/dist/player-signin/panel/view.js +360 -0
- package/dist/player-signin/panel/words.d.ts +8 -0
- package/dist/player-signin/panel/words.js +26 -0
- package/dist/player-signin/scheme-check.d.ts +3 -0
- package/dist/player-signin/scheme-check.js +14 -0
- package/dist/player-signin/scheme.d.ts +13 -0
- package/dist/player-signin/scheme.js +28 -0
- package/dist/player-signin/types.d.ts +116 -0
- package/dist/player-signin/types.js +33 -0
- package/dist/shop/adapters/d1.d.ts +25 -0
- package/dist/shop/adapters/d1.js +200 -0
- package/dist/shop/adapters/firestore.d.ts +53 -0
- package/dist/shop/adapters/firestore.js +245 -0
- package/dist/shop/address.d.ts +14 -0
- package/dist/shop/address.js +104 -0
- package/dist/shop/amount.d.ts +26 -0
- package/dist/shop/amount.js +59 -0
- package/dist/shop/conformance.d.ts +13 -0
- package/dist/shop/conformance.js +203 -0
- package/dist/shop/handler.d.ts +76 -0
- package/dist/shop/handler.js +156 -0
- package/dist/shop/index.d.ts +19 -0
- package/dist/shop/index.js +21 -0
- package/dist/shop/match.d.ts +23 -0
- package/dist/shop/match.js +58 -0
- package/dist/shop/memory.d.ts +13 -0
- package/dist/shop/memory.js +106 -0
- package/dist/shop/nodes.d.ts +47 -0
- package/dist/shop/nodes.js +106 -0
- package/dist/shop/orders.d.ts +47 -0
- package/dist/shop/orders.js +82 -0
- package/dist/shop/panel/index.d.ts +7 -0
- package/dist/shop/panel/index.js +11 -0
- package/dist/shop/panel/looks.d.ts +30 -0
- package/dist/shop/panel/looks.js +61 -0
- package/dist/shop/panel/mount.d.ts +52 -0
- package/dist/shop/panel/mount.js +194 -0
- package/dist/shop/panel/pay.d.ts +24 -0
- package/dist/shop/panel/pay.js +115 -0
- package/dist/shop/panel/qr.d.ts +9 -0
- package/dist/shop/panel/qr.js +281 -0
- package/dist/shop/price.d.ts +23 -0
- package/dist/shop/price.js +43 -0
- package/dist/shop/scan.d.ts +24 -0
- package/dist/shop/scan.js +80 -0
- package/dist/shop/security.d.ts +10 -0
- package/dist/shop/security.js +56 -0
- package/dist/shop/store.d.ts +101 -0
- package/dist/shop/store.js +4 -0
- package/dist/shop/worker.d.ts +43 -0
- package/dist/shop/worker.js +76 -0
- package/package.json +21 -6
- package/src/admin/panel/mount.ts +7 -2
- package/src/player-signin/adapters/d1.sql +45 -0
- package/src/player-signin/adapters/d1.ts +120 -0
- package/src/player-signin/adapters/firestore.ts +194 -0
- package/src/player-signin/conformance.ts +184 -0
- package/src/player-signin/handler.ts +404 -0
- package/src/player-signin/host.ts +12 -0
- package/src/player-signin/index.ts +20 -0
- package/src/player-signin/memory.ts +94 -0
- package/src/player-signin/panel/index.ts +12 -0
- package/src/player-signin/panel/memo.ts +49 -0
- package/src/player-signin/panel/mount.ts +127 -0
- package/src/player-signin/panel/style.css +473 -0
- package/src/player-signin/panel/view.ts +428 -0
- package/src/player-signin/panel/words.ts +32 -0
- package/src/player-signin/scheme-check.ts +14 -0
- package/src/player-signin/scheme.ts +32 -0
- package/src/player-signin/types.ts +103 -0
- package/src/shop/adapters/d1.sql +52 -0
- package/src/shop/adapters/d1.ts +245 -0
- package/src/shop/adapters/firestore.ts +281 -0
- package/src/shop/address.ts +102 -0
- package/src/shop/amount.ts +66 -0
- package/src/shop/conformance.ts +232 -0
- package/src/shop/handler.ts +202 -0
- package/src/shop/index.ts +30 -0
- package/src/shop/match.ts +63 -0
- package/src/shop/memory.ts +102 -0
- package/src/shop/nodes.ts +115 -0
- package/src/shop/orders.ts +93 -0
- package/src/shop/panel/index.ts +15 -0
- package/src/shop/panel/looks.ts +106 -0
- package/src/shop/panel/mount.ts +241 -0
- package/src/shop/panel/pay.ts +171 -0
- package/src/shop/panel/qr.ts +256 -0
- package/src/shop/panel/style.css +534 -0
- package/src/shop/price.ts +52 -0
- package/src/shop/scan.ts +90 -0
- package/src/shop/security.ts +50 -0
- package/src/shop/store.ts +99 -0
- package/src/shop/worker.ts +107 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The player sign-in's store for a game on Cloudflare D1. The tables are in ./d1.sql (the package ships it: "@nano133/nanoterra/player-signin/d1.sql");
|
|
2
|
+
// add it to the game's migrations. They are the game starter's own `signin_requests` and `sessions` (so a starter's data carries over: every
|
|
3
|
+
// player stays signed in) and one more, `signin_once`.
|
|
4
|
+
//
|
|
5
|
+
// import { createD1PlayerSignin } from "@nano133/nanoterra/player-signin/d1";
|
|
6
|
+
// const store = createD1PlayerSignin(env.DB);
|
|
7
|
+
//
|
|
8
|
+
// It reads and writes ONLY the player's rows (scope = 'play'): a game whose admin signs in through the same tables (the starter's scope 'admin')
|
|
9
|
+
// keeps its admin rows, and this adapter never reads, delivers, ends or deletes one. D1 has no transaction that reads and then writes, so each change
|
|
10
|
+
// is ONE statement that holds its own check, and a delivery and its session are one batch (the session is written only when the delivery changed a row).
|
|
11
|
+
import { WAYS } from "../types.js";
|
|
12
|
+
const toRequest = (r) => ({
|
|
13
|
+
id: r.rid ?? r.id,
|
|
14
|
+
starter: r.starter,
|
|
15
|
+
status: r.status === "signedin" ? "signedin" : "waiting",
|
|
16
|
+
expiresAt: Number(r.expires_at),
|
|
17
|
+
...(r.label ? { label: r.label } : {}),
|
|
18
|
+
...(r.nonce ? { nonce: r.nonce } : {}),
|
|
19
|
+
...(r.issued_at ? { issuedAt: Number(r.issued_at) } : {}),
|
|
20
|
+
...(r.account ? { account: r.account } : {}),
|
|
21
|
+
});
|
|
22
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
|
|
23
|
+
const changes = (res) => Number(res?.meta?.changes ?? 0);
|
|
24
|
+
export function createD1PlayerSignin(db, tables = {}) {
|
|
25
|
+
const T = { requests: tables.requests ?? "signin_requests", sessions: tables.sessions ?? "sessions", once: tables.once ?? "signin_once" };
|
|
26
|
+
for (const n of Object.values(T))
|
|
27
|
+
if (!NAME.test(n))
|
|
28
|
+
throw new Error(`not a table name: ${n}`);
|
|
29
|
+
const COLS = "id, rid, starter, status, expires_at, nonce, issued_at, label, account";
|
|
30
|
+
const stores = new Map();
|
|
31
|
+
for (const way of WAYS) {
|
|
32
|
+
stores.set(way, {
|
|
33
|
+
async create(key, r) {
|
|
34
|
+
await db
|
|
35
|
+
.prepare(`INSERT INTO ${T.requests} (id, rid, way, starter, status, expires_at, nonce, issued_at, label, scope) VALUES (?, ?, ?, ?, 'waiting', ?, ?, ?, ?, 'play')`)
|
|
36
|
+
.bind(key, r.id, way, r.starter, r.expiresAt, r.nonce ?? null, r.issuedAt ?? null, r.label ?? null)
|
|
37
|
+
.run();
|
|
38
|
+
},
|
|
39
|
+
async get(key) {
|
|
40
|
+
const r = await db.prepare(`SELECT ${COLS} FROM ${T.requests} WHERE id = ? AND way = ? AND scope = 'play'`).bind(key, way).first();
|
|
41
|
+
return r ? toRequest(r) : null;
|
|
42
|
+
},
|
|
43
|
+
async find(id) {
|
|
44
|
+
const r = await db.prepare(`SELECT ${COLS} FROM ${T.requests} WHERE rid = ? AND way = ? AND scope = 'play'`).bind(id, way).first();
|
|
45
|
+
return r ? { key: r.id, request: toRequest(r) } : null;
|
|
46
|
+
},
|
|
47
|
+
async finish(key, account) {
|
|
48
|
+
return changes(await db.prepare(`UPDATE ${T.requests} SET status = 'signedin', account = ? WHERE id = ? AND way = ? AND status = 'waiting' AND scope = 'play'`).bind(account, key, way).run()) === 1;
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const insertSession = (s, afterChange) => {
|
|
53
|
+
const cols = `INSERT INTO ${T.sessions} (id_hash, address, created_at, expires_at, via, scope)`;
|
|
54
|
+
return db.prepare(afterChange ? `${cols} SELECT ?, ?, ?, ?, ?, 'play' WHERE changes() = 1` : `${cols} VALUES (?, ?, ?, ?, ?, 'play')`).bind(s.idHash, s.address, s.createdAt, s.expiresAt, s.via);
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
requests: (way) => {
|
|
58
|
+
const s = stores.get(way);
|
|
59
|
+
if (!s)
|
|
60
|
+
throw new Error(`no such way: ${String(way)}`);
|
|
61
|
+
return s;
|
|
62
|
+
},
|
|
63
|
+
async deliver(way, key, session) {
|
|
64
|
+
const done = await db.batch([
|
|
65
|
+
db.prepare(`UPDATE ${T.requests} SET delivered = 1 WHERE id = ? AND way = ? AND delivered = 0 AND status = 'signedin' AND scope = 'play'`).bind(key, way),
|
|
66
|
+
insertSession(session, true),
|
|
67
|
+
]);
|
|
68
|
+
return changes(done[0]) === 1;
|
|
69
|
+
},
|
|
70
|
+
async createSession(session) {
|
|
71
|
+
await insertSession(session, false).run();
|
|
72
|
+
},
|
|
73
|
+
async session(idHash, now) {
|
|
74
|
+
const r = await db
|
|
75
|
+
.prepare(`SELECT id_hash, address, created_at, expires_at, via FROM ${T.sessions} WHERE id_hash = ? AND expires_at > ? AND scope = 'play'`)
|
|
76
|
+
.bind(idHash, now)
|
|
77
|
+
.first();
|
|
78
|
+
return r ? { idHash: r.id_hash, address: r.address, createdAt: Number(r.created_at), expiresAt: Number(r.expires_at), via: r.via } : null;
|
|
79
|
+
},
|
|
80
|
+
async endSession(idHash) {
|
|
81
|
+
await db.prepare(`DELETE FROM ${T.sessions} WHERE id_hash = ? AND scope = 'play'`).bind(idHash).run();
|
|
82
|
+
},
|
|
83
|
+
async useOnce(key, ttlMs, address) {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
// A row past its time may be taken again (its signature's 5 minutes are long over); a live one may not.
|
|
86
|
+
const res = await db
|
|
87
|
+
.prepare(`INSERT INTO ${T.once} (key, address, until) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET address = excluded.address, until = excluded.until WHERE ${T.once}.until <= ?`)
|
|
88
|
+
.bind(key, address, now + ttlMs, now)
|
|
89
|
+
.run();
|
|
90
|
+
return changes(res) === 1;
|
|
91
|
+
},
|
|
92
|
+
async forget(address) {
|
|
93
|
+
const done = await db.batch([
|
|
94
|
+
db.prepare(`DELETE FROM ${T.sessions} WHERE address = ? AND scope = 'play'`).bind(address),
|
|
95
|
+
db.prepare(`DELETE FROM ${T.requests} WHERE account = ? AND scope = 'play'`).bind(address),
|
|
96
|
+
db.prepare(`DELETE FROM ${T.once} WHERE address = ?`).bind(address),
|
|
97
|
+
]);
|
|
98
|
+
return done.reduce((n, r) => n + changes(r), 0);
|
|
99
|
+
},
|
|
100
|
+
async clean(now) {
|
|
101
|
+
await db.batch([
|
|
102
|
+
db.prepare(`DELETE FROM ${T.requests} WHERE expires_at < ? AND scope = 'play'`).bind(now - 2 * 3_600_000),
|
|
103
|
+
db.prepare(`DELETE FROM ${T.sessions} WHERE expires_at <= ? AND scope = 'play'`).bind(now),
|
|
104
|
+
db.prepare(`DELETE FROM ${T.once} WHERE until <= ?`).bind(now),
|
|
105
|
+
]);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type PlayerSigninStore } from "../types.js";
|
|
2
|
+
export type PlayerSigninDocSnap = {
|
|
3
|
+
exists: boolean;
|
|
4
|
+
data(): Record<string, unknown> | undefined;
|
|
5
|
+
};
|
|
6
|
+
export interface PlayerSigninDocRef {
|
|
7
|
+
get(): Promise<PlayerSigninDocSnap>;
|
|
8
|
+
}
|
|
9
|
+
export type PlayerSigninQuerySnap = {
|
|
10
|
+
docs: {
|
|
11
|
+
id: string;
|
|
12
|
+
ref: PlayerSigninDocRef;
|
|
13
|
+
data(): Record<string, unknown>;
|
|
14
|
+
}[];
|
|
15
|
+
};
|
|
16
|
+
export interface PlayerSigninQuery {
|
|
17
|
+
where(field: string, op: "==" | "<" | "<=", value: unknown): PlayerSigninQuery;
|
|
18
|
+
limit(n: number): PlayerSigninQuery;
|
|
19
|
+
get(): Promise<PlayerSigninQuerySnap>;
|
|
20
|
+
}
|
|
21
|
+
export interface PlayerSigninCollection extends PlayerSigninQuery {
|
|
22
|
+
doc(id: string): PlayerSigninDocRef;
|
|
23
|
+
}
|
|
24
|
+
/** A transaction: every read before the first write, as Firestore asks. */
|
|
25
|
+
export interface PlayerSigninTransaction {
|
|
26
|
+
get(ref: PlayerSigninDocRef): Promise<PlayerSigninDocSnap>;
|
|
27
|
+
set(ref: PlayerSigninDocRef, data: Record<string, unknown>, options?: {
|
|
28
|
+
merge?: boolean;
|
|
29
|
+
}): unknown;
|
|
30
|
+
delete(ref: PlayerSigninDocRef): unknown;
|
|
31
|
+
}
|
|
32
|
+
export interface PlayerSigninBatch {
|
|
33
|
+
delete(ref: PlayerSigninDocRef): unknown;
|
|
34
|
+
commit(): Promise<unknown>;
|
|
35
|
+
}
|
|
36
|
+
export interface FirestorePlayerSigninLike {
|
|
37
|
+
collection(name: string): PlayerSigninCollection;
|
|
38
|
+
runTransaction<T>(fn: (tx: PlayerSigninTransaction) => Promise<T>): Promise<T>;
|
|
39
|
+
batch(): PlayerSigninBatch;
|
|
40
|
+
}
|
|
41
|
+
export declare function createFirestorePlayerSignin(db: FirestorePlayerSigninLike, collections?: {
|
|
42
|
+
requests?: string;
|
|
43
|
+
sessions?: string;
|
|
44
|
+
once?: string;
|
|
45
|
+
}): PlayerSigninStore;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// The player 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 player's documents (scope "play").
|
|
3
|
+
//
|
|
4
|
+
// import { createFirestorePlayerSignin } from "@nano133/nanoterra/player-signin/firestore";
|
|
5
|
+
// const store = createFirestorePlayerSignin(getFirestore());
|
|
6
|
+
//
|
|
7
|
+
// The data model (the three collection names can be changed):
|
|
8
|
+
// signin_requests/{key} { id, way, starter, status, expiresAt, nonce?, issuedAt?, label?, account?, delivered, scope: "play", deleteAt }
|
|
9
|
+
// sessions/{idHash} { address, createdAt, expiresAt, via, scope: "play", deleteAt }
|
|
10
|
+
// signin_once/{key} { address, until, deleteAt }
|
|
11
|
+
// 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
|
|
12
|
+
// id (Berry Wallet, Nautilus) or the SHA-256 of its nonce (NanChat); `{idHash}` the SHA-256 of the session's token. Every change that reads first is a
|
|
13
|
+
// transaction. Indexes: Firestore's own single-field indexes are enough (`id`, `address`, `account`, `expiresAt`, `until`).
|
|
14
|
+
import { WAYS } from "../types.js";
|
|
15
|
+
const DAY = 86_400_000;
|
|
16
|
+
/** At most this many documents go in one batch (Firestore's limit is 500). */
|
|
17
|
+
const BATCH = 400;
|
|
18
|
+
const text = (v) => (typeof v === "string" ? v : undefined);
|
|
19
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
20
|
+
function requestOf(d, key, way) {
|
|
21
|
+
if (!d || d.scope !== "play" || d.way !== way || typeof d.starter !== "string" || num(d.expiresAt) === undefined)
|
|
22
|
+
return null;
|
|
23
|
+
const nonce = text(d.nonce);
|
|
24
|
+
const issuedAt = num(d.issuedAt);
|
|
25
|
+
const label = text(d.label);
|
|
26
|
+
const account = text(d.account);
|
|
27
|
+
return {
|
|
28
|
+
id: text(d.id) ?? key,
|
|
29
|
+
starter: d.starter,
|
|
30
|
+
status: d.status === "signedin" ? "signedin" : "waiting",
|
|
31
|
+
expiresAt: num(d.expiresAt),
|
|
32
|
+
...(label !== undefined ? { label } : {}),
|
|
33
|
+
...(nonce !== undefined ? { nonce } : {}),
|
|
34
|
+
...(issuedAt !== undefined ? { issuedAt } : {}),
|
|
35
|
+
...(account !== undefined ? { account } : {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function createFirestorePlayerSignin(db, collections = {}) {
|
|
39
|
+
const requestsCol = () => db.collection(collections.requests ?? "signin_requests");
|
|
40
|
+
const sessionsCol = () => db.collection(collections.sessions ?? "sessions");
|
|
41
|
+
const onceCol = () => db.collection(collections.once ?? "signin_once");
|
|
42
|
+
const sessionData = (s) => ({ address: s.address, createdAt: s.createdAt, expiresAt: s.expiresAt, via: s.via, scope: "play", deleteAt: new Date(s.expiresAt) });
|
|
43
|
+
const stores = new Map();
|
|
44
|
+
for (const way of WAYS) {
|
|
45
|
+
stores.set(way, {
|
|
46
|
+
async create(key, r) {
|
|
47
|
+
const ref = requestsCol().doc(key);
|
|
48
|
+
await db.runTransaction(async (tx) => {
|
|
49
|
+
if ((await tx.get(ref)).exists)
|
|
50
|
+
throw new Error("a request with this key exists");
|
|
51
|
+
tx.set(ref, {
|
|
52
|
+
id: r.id,
|
|
53
|
+
way,
|
|
54
|
+
starter: r.starter,
|
|
55
|
+
status: "waiting",
|
|
56
|
+
expiresAt: r.expiresAt,
|
|
57
|
+
...(r.nonce !== undefined ? { nonce: r.nonce } : {}),
|
|
58
|
+
...(r.issuedAt !== undefined ? { issuedAt: r.issuedAt } : {}),
|
|
59
|
+
...(r.label !== undefined ? { label: r.label } : {}),
|
|
60
|
+
delivered: false,
|
|
61
|
+
scope: "play",
|
|
62
|
+
deleteAt: new Date(r.expiresAt + DAY),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
async get(key) {
|
|
67
|
+
return requestOf((await requestsCol().doc(key).get()).data(), key, way);
|
|
68
|
+
},
|
|
69
|
+
async find(id) {
|
|
70
|
+
for (const d of (await requestsCol().where("id", "==", id).limit(10).get()).docs) {
|
|
71
|
+
const request = requestOf(d.data(), d.id, way);
|
|
72
|
+
if (request)
|
|
73
|
+
return { key: d.id, request };
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
},
|
|
77
|
+
async finish(key, account) {
|
|
78
|
+
const ref = requestsCol().doc(key);
|
|
79
|
+
return db.runTransaction(async (tx) => {
|
|
80
|
+
const cur = requestOf((await tx.get(ref)).data(), key, way);
|
|
81
|
+
if (!cur || cur.status !== "waiting")
|
|
82
|
+
return false;
|
|
83
|
+
tx.set(ref, { status: "signedin", account }, { merge: true });
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async function deleteAll(docs) {
|
|
90
|
+
for (let i = 0; i < docs.length; i += BATCH) {
|
|
91
|
+
const b = db.batch();
|
|
92
|
+
for (const d of docs.slice(i, i + BATCH))
|
|
93
|
+
b.delete(d.ref);
|
|
94
|
+
await b.commit();
|
|
95
|
+
}
|
|
96
|
+
return docs.length;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
requests: (way) => {
|
|
100
|
+
const s = stores.get(way);
|
|
101
|
+
if (!s)
|
|
102
|
+
throw new Error(`no such way: ${String(way)}`);
|
|
103
|
+
return s;
|
|
104
|
+
},
|
|
105
|
+
async deliver(way, key, session) {
|
|
106
|
+
const ref = requestsCol().doc(key);
|
|
107
|
+
const sref = sessionsCol().doc(session.idHash);
|
|
108
|
+
return db.runTransaction(async (tx) => {
|
|
109
|
+
const raw = (await tx.get(ref)).data();
|
|
110
|
+
const cur = requestOf(raw, key, way);
|
|
111
|
+
await tx.get(sref);
|
|
112
|
+
if (!cur || cur.status !== "signedin" || raw?.delivered === true)
|
|
113
|
+
return false;
|
|
114
|
+
tx.set(ref, { delivered: true }, { merge: true });
|
|
115
|
+
tx.set(sref, sessionData(session));
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
async createSession(session) {
|
|
120
|
+
const ref = sessionsCol().doc(session.idHash);
|
|
121
|
+
await db.runTransaction(async (tx) => {
|
|
122
|
+
if ((await tx.get(ref)).exists)
|
|
123
|
+
throw new Error("a session with this id exists");
|
|
124
|
+
tx.set(ref, sessionData(session));
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
async session(idHash, now) {
|
|
128
|
+
const d = (await sessionsCol().doc(idHash).get()).data();
|
|
129
|
+
if (!d || d.scope !== "play" || typeof d.address !== "string")
|
|
130
|
+
return null;
|
|
131
|
+
const expiresAt = num(d.expiresAt);
|
|
132
|
+
if (expiresAt === undefined || expiresAt <= now)
|
|
133
|
+
return null;
|
|
134
|
+
return { idHash, address: d.address, createdAt: num(d.createdAt) ?? 0, expiresAt, via: d.via };
|
|
135
|
+
},
|
|
136
|
+
async endSession(idHash) {
|
|
137
|
+
const ref = sessionsCol().doc(idHash);
|
|
138
|
+
await db.runTransaction(async (tx) => {
|
|
139
|
+
const d = (await tx.get(ref)).data();
|
|
140
|
+
if (d && d.scope === "play")
|
|
141
|
+
tx.delete(ref);
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
async useOnce(key, ttlMs, address) {
|
|
145
|
+
const ref = onceCol().doc(key);
|
|
146
|
+
return db.runTransaction(async (tx) => {
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
const d = (await tx.get(ref)).data();
|
|
149
|
+
if (d && (num(d.until) ?? Infinity) > now)
|
|
150
|
+
return false;
|
|
151
|
+
tx.set(ref, { address, until: now + ttlMs, deleteAt: new Date(now + ttlMs) });
|
|
152
|
+
return true;
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
async forget(address) {
|
|
156
|
+
const sessions = (await sessionsCol().where("address", "==", address).get()).docs.filter((d) => d.data().scope === "play");
|
|
157
|
+
const requests = (await requestsCol().where("account", "==", address).get()).docs.filter((d) => d.data().scope === "play");
|
|
158
|
+
const once = (await onceCol().where("address", "==", address).get()).docs;
|
|
159
|
+
return deleteAll([...sessions, ...requests, ...once]);
|
|
160
|
+
},
|
|
161
|
+
async clean(now) {
|
|
162
|
+
const old = (await requestsCol().where("expiresAt", "<", now - 2 * 3_600_000).limit(BATCH).get()).docs.filter((d) => d.data().scope === "play");
|
|
163
|
+
const ended = (await sessionsCol().where("expiresAt", "<=", now).limit(BATCH).get()).docs.filter((d) => d.data().scope === "play");
|
|
164
|
+
const used = (await onceCol().where("until", "<=", now).limit(BATCH).get()).docs;
|
|
165
|
+
await deleteAll([...old, ...ended, ...used]);
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PlayerSigninStore } from "./types.js";
|
|
2
|
+
export type PlayerSigninHarness = {
|
|
3
|
+
store: PlayerSigninStore;
|
|
4
|
+
/** Every row the store keeps (requests, sessions, once-only keys) as one text. `forget` must leave none that holds the player's address. */
|
|
5
|
+
dump(): Promise<string>;
|
|
6
|
+
};
|
|
7
|
+
export type PlayerSigninConformanceCase = {
|
|
8
|
+
name: string;
|
|
9
|
+
run: () => Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export declare function playerSigninStoreConformance(make: () => Promise<PlayerSigninHarness>): PlayerSigninConformanceCase[];
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// The conformance suite of the player sign-in's store: a game that writes its own store (another database) runs it, and so proves that its store
|
|
2
|
+
// keeps every rule the package's adapters keep. The package runs it on the memory store, D1 (on SQLite) and Firestore (a fake, and the emulator).
|
|
3
|
+
//
|
|
4
|
+
// import { playerSigninStoreConformance } from "@nano133/nanoterra/player-signin/conformance";
|
|
5
|
+
// for (const c of playerSigninStoreConformance(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
|
|
8
|
+
// same step; the ways stay apart; a session lasts until its time; a once-only key counts once; "forget" leaves nothing of the player's.
|
|
9
|
+
function check(ok, message) {
|
|
10
|
+
if (!ok)
|
|
11
|
+
throw new Error(message);
|
|
12
|
+
}
|
|
13
|
+
/** An address the suite can find in any text (a store does not check addresses: the handler does). */
|
|
14
|
+
const who = (n) => `nano_3${n}${"x".repeat(59 - n.length)}`;
|
|
15
|
+
const ANN = who("ann");
|
|
16
|
+
const BOB = who("bob");
|
|
17
|
+
const hex = (n, len = 64) => n.toString(16).padStart(len, "0");
|
|
18
|
+
let serial = 0;
|
|
19
|
+
/** A request as a wallet's start makes it: a fresh id each time. */
|
|
20
|
+
function request(at, extra = {}) {
|
|
21
|
+
serial += 1;
|
|
22
|
+
const id = hex(Date.now() * 1000 + serial, 32).slice(-32);
|
|
23
|
+
return { key: id, r: { id, starter: hex(serial + 7), status: "waiting", expiresAt: at + 5 * 60_000, nonce: hex(serial + 99), issuedAt: at, ...extra } };
|
|
24
|
+
}
|
|
25
|
+
const session = (address, at, n, via = "wallet") => ({ idHash: hex(1_000_000 + n + serial++), address, createdAt: at, expiresAt: at + 30 * 86_400_000, via });
|
|
26
|
+
export function playerSigninStoreConformance(make) {
|
|
27
|
+
const cases = [];
|
|
28
|
+
const add = (name, run) => cases.push({ name, run: async () => run(await make(), Date.now()) });
|
|
29
|
+
add("a request reads back as it was made, by its key and by its browser id, and only for its own way", async ({ store }, now) => {
|
|
30
|
+
const { key, r } = request(now, { label: "game.test sign-in ABCD-EFGH" });
|
|
31
|
+
await store.requests("nautilus").create(key, r);
|
|
32
|
+
const back = await store.requests("nautilus").get(key);
|
|
33
|
+
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)}`);
|
|
34
|
+
const found = await store.requests("nautilus").find(r.id);
|
|
35
|
+
check(found?.key === key && found.request.id === r.id, `find: ${JSON.stringify(found)}`);
|
|
36
|
+
for (const other of ["berry", "nanchat"]) {
|
|
37
|
+
check((await store.requests(other).get(key)) === null, `${other} does not see a nautilus request`);
|
|
38
|
+
check((await store.requests(other).find(r.id)) === null, `${other} does not find a nautilus request`);
|
|
39
|
+
}
|
|
40
|
+
check((await store.requests("nautilus").get(hex(12345, 32))) === null, "an unknown key is null");
|
|
41
|
+
});
|
|
42
|
+
add("NanChat's request is kept under the hash of its nonce and found by the browser's id", 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
|
+
});
|
|
48
|
+
add("a second request with the same key is refused", async ({ store }, now) => {
|
|
49
|
+
const { key, r } = request(now);
|
|
50
|
+
await store.requests("berry").create(key, r);
|
|
51
|
+
let threw = false;
|
|
52
|
+
try {
|
|
53
|
+
await store.requests("berry").create(key, { ...r, starter: hex(1) });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
threw = true;
|
|
57
|
+
}
|
|
58
|
+
check(threw, "the second create throws");
|
|
59
|
+
check((await store.requests("berry").get(key))?.starter === r.starter, "the first request is unchanged");
|
|
60
|
+
});
|
|
61
|
+
add("finish signs a waiting request in once, with its account; a second finish, or one on an unknown key, is false", async ({ store }, now) => {
|
|
62
|
+
const { key, r } = request(now);
|
|
63
|
+
await store.requests("berry").create(key, r);
|
|
64
|
+
check(await store.requests("berry").finish(key, ANN), "the first finish");
|
|
65
|
+
check(!(await store.requests("berry").finish(key, BOB)), "a second finish is false");
|
|
66
|
+
const back = await store.requests("berry").get(key);
|
|
67
|
+
check(back?.status === "signedin" && back.account === ANN, `the account stays the first: ${JSON.stringify(back)}`);
|
|
68
|
+
check(!(await store.requests("berry").finish(hex(777, 32), ANN)), "an unknown key");
|
|
69
|
+
check(!(await store.requests("nautilus").finish(key, ANN)), "another way cannot finish it");
|
|
70
|
+
});
|
|
71
|
+
add("two finishes at the same moment: exactly one is true", async ({ store }, now) => {
|
|
72
|
+
const { key, r } = request(now);
|
|
73
|
+
await store.requests("berry").create(key, r);
|
|
74
|
+
const both = await Promise.all([store.requests("berry").finish(key, ANN), store.requests("berry").finish(key, BOB)]);
|
|
75
|
+
check(both.filter(Boolean).length === 1, `results: ${JSON.stringify(both)}`);
|
|
76
|
+
});
|
|
77
|
+
add("deliver: false while waiting; then once, with its session; a second delivery writes no session", async ({ store }, now) => {
|
|
78
|
+
const { key, r } = request(now);
|
|
79
|
+
await store.requests("berry").create(key, r);
|
|
80
|
+
const early = session(ANN, now, 1);
|
|
81
|
+
check(!(await store.deliver("berry", key, early)), "a waiting request is not delivered");
|
|
82
|
+
check((await store.session(early.idHash, now)) === null, "and no session was written");
|
|
83
|
+
await store.requests("berry").finish(key, ANN);
|
|
84
|
+
const s1 = session(ANN, now, 2);
|
|
85
|
+
check(await store.deliver("berry", key, s1), "the first delivery");
|
|
86
|
+
const read = await store.session(s1.idHash, now);
|
|
87
|
+
check(read?.address === ANN && read.via === "wallet" && read.expiresAt === s1.expiresAt && read.createdAt === s1.createdAt, `the session: ${JSON.stringify(read)}`);
|
|
88
|
+
const s2 = session(ANN, now, 3);
|
|
89
|
+
check(!(await store.deliver("berry", key, s2)), "a second delivery is false");
|
|
90
|
+
check((await store.session(s2.idHash, now)) === null, "and wrote no session");
|
|
91
|
+
check(!(await store.deliver("nautilus", key, session(ANN, now, 4))), "another way cannot deliver it");
|
|
92
|
+
check(!(await store.deliver("berry", hex(888, 32), session(ANN, now, 5))), "an unknown key");
|
|
93
|
+
});
|
|
94
|
+
add("two deliveries at the same moment: one is true, and one session is written", async ({ store, dump }, now) => {
|
|
95
|
+
const { key, r } = request(now);
|
|
96
|
+
await store.requests("nautilus").create(key, r);
|
|
97
|
+
await store.requests("nautilus").finish(key, ANN);
|
|
98
|
+
const a = session(ANN, now, 10);
|
|
99
|
+
const b = session(ANN, now, 11);
|
|
100
|
+
const both = await Promise.all([store.deliver("nautilus", key, a), store.deliver("nautilus", key, b)]);
|
|
101
|
+
check(both.filter(Boolean).length === 1, `results: ${JSON.stringify(both)}`);
|
|
102
|
+
const live = [await store.session(a.idHash, now), await store.session(b.idHash, now)].filter(Boolean);
|
|
103
|
+
check(live.length === 1, `sessions written: ${live.length} (${(await dump()).slice(0, 300)})`);
|
|
104
|
+
});
|
|
105
|
+
add("a session reads while it lasts, with its `via`, and not from its end; ending it removes it", async ({ store }, now) => {
|
|
106
|
+
const s = session(BOB, now, 20, "passkey");
|
|
107
|
+
await store.createSession(s);
|
|
108
|
+
const read = await store.session(s.idHash, now);
|
|
109
|
+
check(read?.address === BOB && read.via === "passkey" && read.idHash === s.idHash, `read: ${JSON.stringify(read)}`);
|
|
110
|
+
check((await store.session(s.idHash, s.expiresAt)) === null, "not at its end");
|
|
111
|
+
check((await store.session(s.idHash, s.expiresAt + 1)) === null, "not after it");
|
|
112
|
+
check((await store.session(hex(999), now)) === null, "an unknown id");
|
|
113
|
+
await store.endSession(s.idHash);
|
|
114
|
+
check((await store.session(s.idHash, now)) === null, "ended");
|
|
115
|
+
await store.endSession(s.idHash);
|
|
116
|
+
});
|
|
117
|
+
add("a once-only key counts once; another key counts; two at the same moment: one", async ({ store }, now) => {
|
|
118
|
+
const k = hex(now);
|
|
119
|
+
check(await store.useOnce(k, 600_000, ANN), "the first use");
|
|
120
|
+
check(!(await store.useOnce(k, 600_000, BOB)), "the second use");
|
|
121
|
+
check(await store.useOnce(hex(now + 1), 600_000, ANN), "another key");
|
|
122
|
+
const k2 = hex(now + 2);
|
|
123
|
+
const both = await Promise.all([store.useOnce(k2, 600_000, ANN), store.useOnce(k2, 600_000, ANN)]);
|
|
124
|
+
check(both.filter(Boolean).length === 1, `two at once: ${JSON.stringify(both)}`);
|
|
125
|
+
});
|
|
126
|
+
add("forget leaves nothing of the player's (sessions, signed requests, once-only keys) and every row of another player", async ({ store, dump }, now) => {
|
|
127
|
+
for (const [address, n] of [[ANN, 30], [BOB, 40]]) {
|
|
128
|
+
await store.createSession(session(address, now, n, "passkey"));
|
|
129
|
+
const { key, r } = request(now);
|
|
130
|
+
await store.requests("berry").create(key, r);
|
|
131
|
+
await store.requests("berry").finish(key, address);
|
|
132
|
+
await store.deliver("berry", key, session(address, now, n + 1));
|
|
133
|
+
await store.useOnce(hex(now + n), 600_000, address);
|
|
134
|
+
}
|
|
135
|
+
const before = await dump();
|
|
136
|
+
check(before.includes(ANN) && before.includes(BOB), "both players are in the store");
|
|
137
|
+
const gone = await store.forget(ANN);
|
|
138
|
+
check(gone >= 4, `rows that went: ${gone}`);
|
|
139
|
+
const after = await dump();
|
|
140
|
+
check(!after.includes(ANN), `the player is left in the store: ${after.slice(0, 400)}`);
|
|
141
|
+
check(after.split(BOB).length - 1 >= 4, "the other player's rows stay");
|
|
142
|
+
check((await store.forget(ANN)) === 0, "a second forget finds nothing");
|
|
143
|
+
});
|
|
144
|
+
add("clean deletes ended sessions and requests 2 hours after they ended, and keeps the rest", async ({ store }, now) => {
|
|
145
|
+
const ended = { ...session(ANN, now - 40 * 86_400_000, 50), expiresAt: now - 1 };
|
|
146
|
+
const live = session(ANN, now, 51);
|
|
147
|
+
await store.createSession(ended);
|
|
148
|
+
await store.createSession(live);
|
|
149
|
+
const old = request(now - 3 * 3_600_000);
|
|
150
|
+
const fresh = request(now);
|
|
151
|
+
await store.requests("berry").create(old.key, old.r);
|
|
152
|
+
await store.requests("berry").create(fresh.key, fresh.r);
|
|
153
|
+
await store.clean(now);
|
|
154
|
+
check((await store.session(live.idHash, now)) !== null, "the live session stays");
|
|
155
|
+
check((await store.session(ended.idHash, now - 2)) === null, "the ended session went");
|
|
156
|
+
check((await store.requests("berry").get(old.key)) === null, "the old request went");
|
|
157
|
+
check((await store.requests("berry").get(fresh.key)) !== null, "the fresh request stays");
|
|
158
|
+
});
|
|
159
|
+
return cases;
|
|
160
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type PlayerSession, type PlayerSigninStore, type Via, type Way } from "./types.js";
|
|
2
|
+
/** The address that belongs to nobody (its key is all zeros): Nautilus names it while the game has no address of its own. */
|
|
3
|
+
export declare const EXAMPLE_ADDRESS = "nano_1111111111111111111111111111111111111111111111111111hifc8npp";
|
|
4
|
+
export type PlayerSigninOptions = {
|
|
5
|
+
store: PlayerSigninStore;
|
|
6
|
+
/**
|
|
7
|
+
* A Nano address of the game's own (the starter's RECEIVE_ADDRESS). Nautilus only NAMES it in its request and in the text it signs: no key, no
|
|
8
|
+
* payment. Null or none: the example address.
|
|
9
|
+
*/
|
|
10
|
+
siteAddress?: string | null;
|
|
11
|
+
/** Where the sign-in routes are. Default "/api/signin". */
|
|
12
|
+
base?: string;
|
|
13
|
+
/** The sign-out route. Default "/api/signout". */
|
|
14
|
+
signOutPath?: string;
|
|
15
|
+
/** The wallet apps the game offers, in the sheet's order. Default all three: ["berry", "nanchat", "nautilus"]. An empty list offers none. */
|
|
16
|
+
ways?: readonly Way[];
|
|
17
|
+
/** The passkey route. Default true. */
|
|
18
|
+
passkey?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* The game's host exactly as players see it ("mygame.com", with a port when it has one). Every signed text names it. Default: the host of each
|
|
21
|
+
* request. When it is set, a call to any other host is refused (403).
|
|
22
|
+
*/
|
|
23
|
+
domain?: string;
|
|
24
|
+
/** True when the game's admin signs in on the same paths (the starter's `scope: "admin"`): its calls answer null. Default false. */
|
|
25
|
+
sharedPaths?: boolean;
|
|
26
|
+
/** The cookies' names. Default { session: "session", starter: "starter" } (the starter's). On https they get the __Host- prefix. */
|
|
27
|
+
cookies?: {
|
|
28
|
+
session?: string;
|
|
29
|
+
starter?: string;
|
|
30
|
+
};
|
|
31
|
+
/** How long a session lasts, in days. Default 30. */
|
|
32
|
+
sessionDays?: number;
|
|
33
|
+
/** The shared limiter (sign-in starts, passkey sign-ins, NanChat's totals): false means "too many". Default: a fixed window in this copy's memory. */
|
|
34
|
+
limit?: (key: string, limit: number, windowMs: number, now: number) => boolean | Promise<boolean>;
|
|
35
|
+
/** The visitor's key for the limits: a keyed hash of the IP address, never the address. Default: the CF-Connecting-IP header as it is. */
|
|
36
|
+
visitor?: (request: Request) => Promise<string>;
|
|
37
|
+
/** The person check (Turnstile) of a start and a passkey sign-in: true when `token` passes. No hook: no check. */
|
|
38
|
+
personCheck?: (token: unknown, request: Request) => Promise<boolean>;
|
|
39
|
+
/** The time now in ms. Default Date.now. */
|
|
40
|
+
now?: () => number;
|
|
41
|
+
/** Told about a fault (a store). The player gets a plain 500 and never sees it. */
|
|
42
|
+
onError?: (e: unknown) => void;
|
|
43
|
+
};
|
|
44
|
+
export type PlayerSignin = {
|
|
45
|
+
base: string;
|
|
46
|
+
/** Answers a sign-in route, or null when the request is not one (or is the admin's, with `sharedPaths`). */
|
|
47
|
+
handle(request: Request): Promise<Response | null>;
|
|
48
|
+
/** The signed-in player of this request (their Nano address), or null. The shop's and the friends' `player` hook. Never the admin's login. */
|
|
49
|
+
player(request: Request): Promise<string | null>;
|
|
50
|
+
/** The session of this request: the address, when it was made and how. */
|
|
51
|
+
session(request: Request): Promise<PlayerSession | null>;
|
|
52
|
+
/** Makes a new session for `address` (the game's own code exchange: via "nanoterra") and returns the Set-Cookie value to send. */
|
|
53
|
+
signIn(request: Request, address: string, via: Via): Promise<string>;
|
|
54
|
+
/** Ends this request's session and returns the Set-Cookie value that clears the cookie. */
|
|
55
|
+
signOut(request: Request): Promise<string>;
|
|
56
|
+
/** "Delete my data": every session and sign-in row of the player. Returns how many rows went. */
|
|
57
|
+
forget(player: string): Promise<number>;
|
|
58
|
+
/** The clean-up (a cron): old requests, ended sessions, used once-only keys. */
|
|
59
|
+
clean(now?: number): Promise<void>;
|
|
60
|
+
};
|
|
61
|
+
export declare function sha256Hex(text: string): Promise<string>;
|
|
62
|
+
/** A cookie for this site only: on https it gets the __Host- prefix and Secure (the starter's exact form). */
|
|
63
|
+
export declare function cookieHeader(req: Request, name: string, value: string, maxAgeSec: number): string;
|
|
64
|
+
export declare function createPlayerSignin(options: PlayerSigninOptions): PlayerSignin;
|