@nano133/nanoterra 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +180 -10
  3. package/ai/SPEC.md +37 -2
  4. package/dist/admin/guard.d.ts +9 -1
  5. package/dist/admin/guard.js +12 -4
  6. package/dist/admin/panel/card.d.ts +5 -0
  7. package/dist/admin/panel/card.js +3 -0
  8. package/dist/admin/panel/icons.d.ts +9 -0
  9. package/dist/admin/panel/icons.js +1 -0
  10. package/dist/admin/panel/index.d.ts +1 -1
  11. package/dist/admin/panel/mount.d.ts +7 -0
  12. package/dist/admin/panel/mount.js +4 -1
  13. package/dist/admin-signin/adapters/d1.d.ts +12 -0
  14. package/dist/admin-signin/adapters/d1.js +161 -0
  15. package/dist/admin-signin/adapters/firestore.d.ts +52 -0
  16. package/dist/admin-signin/adapters/firestore.js +248 -0
  17. package/dist/admin-signin/browser.d.ts +4 -0
  18. package/dist/admin-signin/browser.js +36 -0
  19. package/dist/admin-signin/conformance.d.ts +18 -0
  20. package/dist/admin-signin/conformance.js +275 -0
  21. package/dist/admin-signin/handler.d.ts +69 -0
  22. package/dist/admin-signin/handler.js +488 -0
  23. package/dist/admin-signin/index.d.ts +8 -0
  24. package/dist/admin-signin/index.js +16 -0
  25. package/dist/admin-signin/memory.d.ts +17 -0
  26. package/dist/admin-signin/memory.js +147 -0
  27. package/dist/admin-signin/panel/index.d.ts +6 -0
  28. package/dist/admin-signin/panel/index.js +7 -0
  29. package/dist/admin-signin/panel/passkeys.d.ts +43 -0
  30. package/dist/admin-signin/panel/passkeys.js +202 -0
  31. package/dist/admin-signin/panel/view.d.ts +52 -0
  32. package/dist/admin-signin/panel/view.js +229 -0
  33. package/dist/admin-signin/types.d.ts +155 -0
  34. package/dist/admin-signin/types.js +59 -0
  35. package/package.json +9 -2
  36. package/src/admin/guard.ts +19 -5
  37. package/src/admin/panel/card.ts +5 -0
  38. package/src/admin/panel/icons.ts +1 -0
  39. package/src/admin/panel/index.ts +1 -1
  40. package/src/admin/panel/mount.ts +11 -1
  41. package/src/admin/panel/style.css +44 -0
  42. package/src/admin-signin/adapters/d1.sql +62 -0
  43. package/src/admin-signin/adapters/d1.ts +170 -0
  44. package/src/admin-signin/adapters/firestore.ts +274 -0
  45. package/src/admin-signin/browser.ts +38 -0
  46. package/src/admin-signin/conformance.ts +308 -0
  47. package/src/admin-signin/handler.ts +545 -0
  48. package/src/admin-signin/index.ts +34 -0
  49. package/src/admin-signin/memory.ts +141 -0
  50. package/src/admin-signin/panel/index.ts +11 -0
  51. package/src/admin-signin/panel/passkeys.ts +245 -0
  52. package/src/admin-signin/panel/view.ts +279 -0
  53. package/src/admin-signin/types.ts +146 -0
  54. package/src/player-signin/panel/style.css +8 -1
@@ -0,0 +1,161 @@
1
+ // The admin sign-in's store for a game on Cloudflare D1, on the game starter's OWN tables and columns: `signin_requests` and `sessions` (their admin
2
+ // rows, scope = 'admin') and `admin_logins`. A starter moves onto it with no migration, and every creator who is signed in to /admin stays signed in.
3
+ // A new game adds ./d1.sql to its migrations ("@nano133/nanoterra/admin-signin/d1.sql": the same tables, `IF NOT EXISTS`). The owner's passkeys (since
4
+ // 0.12.0) use two more tables, `admin_passkeys` and `admin_once` (the end of d1.sql): only a game that turns the passkeys on needs them.
5
+ //
6
+ // import { createD1AdminSignin } from "@nano133/nanoterra/admin-signin/d1";
7
+ // const store = createD1AdminSignin(env.DB);
8
+ //
9
+ // It reads and writes ONLY the admin's rows (scope = 'admin'): a game whose players sign in through the same tables (the player sign-in's scope 'play')
10
+ // keeps its rows, and this adapter never reads, delivers, ends or deletes one. D1 has no transaction that reads and then writes, so each change is ONE
11
+ // 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).
12
+ // "Sign out everywhere" is the same two statements as the admin store's (`createD1Store(db, { logins: true })`): either one may run it.
13
+ import { ADMIN_LOGIN_KEEP_MS, ADMIN_PASSKEY_LIMIT, ADMIN_WAYS } from "../types.js";
14
+ const toRequest = (r) => ({
15
+ id: r.rid ?? r.id,
16
+ starter: r.starter,
17
+ status: r.status === "signedin" ? "signedin" : "waiting",
18
+ expiresAt: Number(r.expires_at),
19
+ ...(r.label ? { label: r.label } : {}),
20
+ ...(r.nonce ? { nonce: r.nonce } : {}),
21
+ ...(r.issued_at ? { issuedAt: Number(r.issued_at) } : {}),
22
+ ...(r.account ? { account: r.account } : {}),
23
+ });
24
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
25
+ const changes = (res) => Number(res?.meta?.changes ?? 0);
26
+ /** True for D1's (and SQLite's) "no such table": a game that has not added the passkeys' tables. */
27
+ const noTable = (e) => /no such table/i.test(String(e?.message ?? e));
28
+ export function createD1AdminSignin(db, tables = {}) {
29
+ const T = {
30
+ requests: tables.requests ?? "signin_requests",
31
+ sessions: tables.sessions ?? "sessions",
32
+ logins: tables.logins ?? "admin_logins",
33
+ passkeys: tables.passkeys ?? "admin_passkeys",
34
+ once: tables.once ?? "admin_once",
35
+ };
36
+ for (const n of Object.values(T))
37
+ if (!NAME.test(n))
38
+ throw new Error(`not a table name: ${n}`);
39
+ const COLS = "id, rid, starter, status, expires_at, nonce, issued_at, label, account";
40
+ const stores = new Map();
41
+ for (const way of ADMIN_WAYS) {
42
+ stores.set(way, {
43
+ async create(key, r) {
44
+ // The starter's own insert: `scope` is written when the request is created, so a request started for the admin is the admin's for good.
45
+ await db
46
+ .prepare(`INSERT INTO ${T.requests} (id, rid, way, starter, status, expires_at, nonce, issued_at, label, scope) VALUES (?, ?, ?, ?, 'waiting', ?, ?, ?, ?, 'admin')`)
47
+ .bind(key, r.id, way, r.starter, r.expiresAt, r.nonce ?? null, r.issuedAt ?? null, r.label ?? null)
48
+ .run();
49
+ },
50
+ async get(key) {
51
+ const r = await db.prepare(`SELECT ${COLS} FROM ${T.requests} WHERE id = ? AND way = ? AND scope = 'admin'`).bind(key, way).first();
52
+ return r ? toRequest(r) : null;
53
+ },
54
+ async find(id) {
55
+ const r = await db.prepare(`SELECT ${COLS} FROM ${T.requests} WHERE rid = ? AND way = ? AND scope = 'admin'`).bind(id, way).first();
56
+ return r ? { key: r.id, request: toRequest(r) } : null;
57
+ },
58
+ async finish(key, account) {
59
+ return changes(await db.prepare(`UPDATE ${T.requests} SET status = 'signedin', account = ? WHERE id = ? AND way = ? AND status = 'waiting' AND scope = 'admin'`).bind(account, key, way).run()) === 1;
60
+ },
61
+ });
62
+ }
63
+ return {
64
+ requests: (way) => {
65
+ const s = stores.get(way);
66
+ if (!s)
67
+ throw new Error(`no such way: ${String(way)}`);
68
+ return s;
69
+ },
70
+ async deliver(way, key, s) {
71
+ // The starter's batch: the delivery, and the session only when the delivery changed a row.
72
+ const done = await db.batch([
73
+ db.prepare(`UPDATE ${T.requests} SET delivered = 1 WHERE id = ? AND way = ? AND delivered = 0 AND status = 'signedin' AND scope = 'admin'`).bind(key, way),
74
+ db.prepare(`INSERT INTO ${T.sessions} (id_hash, address, created_at, expires_at, via, scope) SELECT ?, ?, ?, ?, ?, 'admin' WHERE changes() = 1`).bind(s.idHash, s.address, s.createdAt, s.expiresAt, s.via),
75
+ ]);
76
+ return changes(done[0]) === 1;
77
+ },
78
+ async session(idHash, now) {
79
+ const r = await db
80
+ .prepare(`SELECT id_hash, address, created_at, expires_at, via FROM ${T.sessions} WHERE id_hash = ? AND expires_at > ? AND scope = 'admin'`)
81
+ .bind(idHash, now)
82
+ .first();
83
+ return r ? { idHash: r.id_hash, address: r.address, createdAt: Number(r.created_at), expiresAt: Number(r.expires_at), via: String(r.via) } : null;
84
+ },
85
+ async endSession(idHash) {
86
+ await db.prepare(`DELETE FROM ${T.sessions} WHERE id_hash = ? AND scope = 'admin'`).bind(idHash).run();
87
+ },
88
+ async signOutEverywhere() {
89
+ const done = await db.batch([
90
+ db.prepare(`DELETE FROM ${T.sessions} WHERE scope = 'admin'`),
91
+ db.prepare(`UPDATE ${T.requests} SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0`),
92
+ ]);
93
+ return changes(done[0]);
94
+ },
95
+ async recordLogin(login) {
96
+ await db.batch([
97
+ db.prepare(`INSERT INTO ${T.logins} (at, country, browser) VALUES (?, ?, ?)`).bind(login.at, login.country, login.browser),
98
+ db.prepare(`DELETE FROM ${T.logins} WHERE at < ?`).bind(login.at - ADMIN_LOGIN_KEEP_MS),
99
+ ]);
100
+ },
101
+ async recentLogins(limit) {
102
+ const n = Math.min(Math.max(Math.floor(Number(limit)) || 0, 0), 50);
103
+ const rows = (await db.prepare(`SELECT at, country, browser FROM ${T.logins} ORDER BY at DESC, id DESC LIMIT ?`).bind(n).all()).results;
104
+ return rows.map((r) => ({ at: Number(r.at), country: r.country, browser: r.browser }));
105
+ },
106
+ async clean(now) {
107
+ // The starter's clean-up of its admin rows (its `cleanUp`, without the game's own `hits`).
108
+ await db.batch([
109
+ db.prepare(`DELETE FROM ${T.requests} WHERE expires_at < ? AND scope = 'admin'`).bind(now - 2 * 3_600_000),
110
+ db.prepare(`DELETE FROM ${T.sessions} WHERE expires_at < ? AND scope = 'admin'`).bind(now),
111
+ db.prepare(`DELETE FROM ${T.logins} WHERE at < ?`).bind(now - ADMIN_LOGIN_KEEP_MS),
112
+ ]);
113
+ // The used passkey signatures, on their own: a game without the passkeys' tables (they came in 0.12.0) still cleans the rest.
114
+ try {
115
+ await db.prepare(`DELETE FROM ${T.once} WHERE until <= ?`).bind(now).run();
116
+ }
117
+ catch (e) {
118
+ if (!noTable(e))
119
+ throw e;
120
+ }
121
+ },
122
+ async createSession(s) {
123
+ await db.prepare(`INSERT INTO ${T.sessions} (id_hash, address, created_at, expires_at, via, scope) VALUES (?, ?, ?, ?, ?, 'admin')`).bind(s.idHash, s.address, s.createdAt, s.expiresAt, s.via).run();
124
+ },
125
+ async linkPasskey(owner, address, at) {
126
+ // ONE statement holds the limit: the row is written only while the owner has fewer than the limit, and never twice.
127
+ const res = await db
128
+ .prepare(`INSERT INTO ${T.passkeys} (owner, address, created_at) SELECT ?, ?, ? WHERE (SELECT COUNT(*) FROM ${T.passkeys} WHERE owner = ?) < ? ON CONFLICT (owner, address) DO NOTHING`)
129
+ .bind(owner, address, at, owner, ADMIN_PASSKEY_LIMIT)
130
+ .run();
131
+ if (changes(res) === 1)
132
+ return "linked";
133
+ const had = await db.prepare(`SELECT 1 AS one FROM ${T.passkeys} WHERE owner = ? AND address = ?`).bind(owner, address).first();
134
+ return had ? "exists" : "full";
135
+ },
136
+ async unlinkPasskey(owner, address) {
137
+ // The link, and the passkey's admin sessions with it.
138
+ const done = await db.batch([
139
+ db.prepare(`DELETE FROM ${T.passkeys} WHERE owner = ? AND address = ?`).bind(owner, address),
140
+ db.prepare(`DELETE FROM ${T.sessions} WHERE scope = 'admin' AND via = 'passkey' AND address = ?`).bind(address),
141
+ ]);
142
+ return changes(done[0]) > 0;
143
+ },
144
+ async passkeys(owner) {
145
+ const rows = (await db.prepare(`SELECT address, created_at FROM ${T.passkeys} WHERE owner = ? ORDER BY created_at, address`).bind(owner).all()).results;
146
+ return rows.map((r) => ({ address: String(r.address), createdAt: Number(r.created_at) }));
147
+ },
148
+ async linked(owner, address) {
149
+ return (await db.prepare(`SELECT 1 AS one FROM ${T.passkeys} WHERE owner = ? AND address = ?`).bind(owner, address).first()) !== null;
150
+ },
151
+ async useOnce(key, ttlMs) {
152
+ const now = Date.now();
153
+ // A row past its time may be taken again (its signature's window is long over); a live one may not.
154
+ const res = await db
155
+ .prepare(`INSERT INTO ${T.once} (key, until) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET until = excluded.until WHERE ${T.once}.until <= ?`)
156
+ .bind(key, now + ttlMs, now)
157
+ .run();
158
+ return changes(res) === 1;
159
+ },
160
+ };
161
+ }
@@ -0,0 +1,52 @@
1
+ import { type AdminSigninStore } from "../types.js";
2
+ export type AdminSigninDocSnap = {
3
+ exists: boolean;
4
+ data(): Record<string, unknown> | undefined;
5
+ };
6
+ export interface AdminSigninDocRef {
7
+ get(): Promise<AdminSigninDocSnap>;
8
+ }
9
+ export type AdminSigninQuerySnap = {
10
+ docs: {
11
+ id: string;
12
+ ref: AdminSigninDocRef;
13
+ data(): Record<string, unknown>;
14
+ }[];
15
+ };
16
+ export interface AdminSigninQuery {
17
+ where(field: string, op: "==" | "<", value: unknown): AdminSigninQuery;
18
+ orderBy(field: string, direction?: "asc" | "desc"): AdminSigninQuery;
19
+ limit(n: number): AdminSigninQuery;
20
+ get(): Promise<AdminSigninQuerySnap>;
21
+ }
22
+ export interface AdminSigninCollection extends AdminSigninQuery {
23
+ doc(id: string): AdminSigninDocRef;
24
+ }
25
+ /** A transaction: every read before the first write, as Firestore asks. */
26
+ export interface AdminSigninTransaction {
27
+ get(ref: AdminSigninDocRef): Promise<AdminSigninDocSnap>;
28
+ set(ref: AdminSigninDocRef, data: Record<string, unknown>, options?: {
29
+ merge?: boolean;
30
+ }): unknown;
31
+ delete(ref: AdminSigninDocRef): unknown;
32
+ }
33
+ export interface AdminSigninBatch {
34
+ set(ref: AdminSigninDocRef, data: Record<string, unknown>, options?: {
35
+ merge?: boolean;
36
+ }): unknown;
37
+ delete(ref: AdminSigninDocRef): unknown;
38
+ commit(): Promise<unknown>;
39
+ }
40
+ export interface FirestoreAdminSigninLike {
41
+ collection(name: string): AdminSigninCollection;
42
+ runTransaction<T>(fn: (tx: AdminSigninTransaction) => Promise<T>): Promise<T>;
43
+ batch(): AdminSigninBatch;
44
+ }
45
+ export type FirestoreAdminSigninCollections = {
46
+ requests?: string;
47
+ sessions?: string;
48
+ logins?: string;
49
+ passkeys?: string;
50
+ once?: string;
51
+ };
52
+ export declare function createFirestoreAdminSignin(db: FirestoreAdminSigninLike, collections?: FirestoreAdminSigninCollections): Required<AdminSigninStore>;
@@ -0,0 +1,248 @@
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
+ // admin_passkeys/{owner} { owner, passkeys: [{ address, createdAt }] } (since 0.12.0: the owner's linked passkeys, at most 10; no deleteAt)
13
+ // admin_once/{key} { until, deleteAt } (since 0.12.0: the passkeys' used signatures)
14
+ // Times are ms numbers; `deleteAt` is a Date (a Timestamp in Firestore): set a TTL policy on `deleteAt` for each collection that has it. `{key}` is the
15
+ // request's id; `{idHash}` the SHA-256 of "admin:" and the session's token. Every change that reads first is a transaction (the owner's passkeys are ONE
16
+ // document, so a link holds the limit in its transaction). Indexes: Firestore's own single-field indexes are enough (`id`, `scope`, `expiresAt`, `at`,
17
+ // `address`, `until`).
18
+ //
19
+ // The admin store for Firestore (`createFirestoreStore`) has no sign-in list and no "Sign out everywhere": give it this sign-in's,
20
+ // `store.recentLogins = signin.recentLogins` and `store.signOutEverywhere = signin.signOutEverywhere`.
21
+ import { ADMIN_LOGIN_KEEP_MS, ADMIN_PASSKEY_LIMIT, ADMIN_WAYS } from "../types.js";
22
+ const DAY = 86_400_000;
23
+ /** At most this many documents go in one batch (Firestore's limit is 500). */
24
+ const BATCH = 400;
25
+ const text = (v) => (typeof v === "string" ? v : undefined);
26
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
27
+ const hex = (bytes) => Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (x) => x.toString(16).padStart(2, "0")).join("");
28
+ function requestOf(d, key, way) {
29
+ if (!d || d.scope !== "admin" || d.way !== way || typeof d.starter !== "string" || num(d.expiresAt) === undefined)
30
+ return null;
31
+ const nonce = text(d.nonce);
32
+ const issuedAt = num(d.issuedAt);
33
+ const label = text(d.label);
34
+ const account = text(d.account);
35
+ return {
36
+ id: text(d.id) ?? key,
37
+ starter: d.starter,
38
+ status: d.status === "signedin" ? "signedin" : "waiting",
39
+ expiresAt: num(d.expiresAt),
40
+ ...(label !== undefined ? { label } : {}),
41
+ ...(nonce !== undefined ? { nonce } : {}),
42
+ ...(issuedAt !== undefined ? { issuedAt } : {}),
43
+ ...(account !== undefined ? { account } : {}),
44
+ };
45
+ }
46
+ /** The links a passkeys document holds: only well-formed ones. */
47
+ function linksOf(d) {
48
+ const list = Array.isArray(d?.passkeys) ? d.passkeys : [];
49
+ return list.flatMap((p) => {
50
+ const v = p;
51
+ const address = text(v?.address);
52
+ return address !== undefined ? [{ address, createdAt: num(v?.createdAt) ?? 0 }] : [];
53
+ });
54
+ }
55
+ export function createFirestoreAdminSignin(db, collections = {}) {
56
+ const requestsCol = () => db.collection(collections.requests ?? "signin_requests");
57
+ const sessionsCol = () => db.collection(collections.sessions ?? "sessions");
58
+ const loginsCol = () => db.collection(collections.logins ?? "admin_logins");
59
+ const passkeysCol = () => db.collection(collections.passkeys ?? "admin_passkeys");
60
+ const onceCol = () => db.collection(collections.once ?? "admin_once");
61
+ const sessionData = (s) => ({ address: s.address, createdAt: s.createdAt, expiresAt: s.expiresAt, via: s.via, scope: "admin", deleteAt: new Date(s.expiresAt) });
62
+ const stores = new Map();
63
+ for (const way of ADMIN_WAYS) {
64
+ stores.set(way, {
65
+ async create(key, r) {
66
+ const ref = requestsCol().doc(key);
67
+ await db.runTransaction(async (tx) => {
68
+ if ((await tx.get(ref)).exists)
69
+ throw new Error("a request with this key exists");
70
+ tx.set(ref, {
71
+ id: r.id,
72
+ way,
73
+ starter: r.starter,
74
+ status: "waiting",
75
+ expiresAt: r.expiresAt,
76
+ ...(r.nonce !== undefined ? { nonce: r.nonce } : {}),
77
+ ...(r.issuedAt !== undefined ? { issuedAt: r.issuedAt } : {}),
78
+ ...(r.label !== undefined ? { label: r.label } : {}),
79
+ delivered: false,
80
+ scope: "admin",
81
+ deleteAt: new Date(r.expiresAt + DAY),
82
+ });
83
+ });
84
+ },
85
+ async get(key) {
86
+ return requestOf((await requestsCol().doc(key).get()).data(), key, way);
87
+ },
88
+ async find(id) {
89
+ for (const d of (await requestsCol().where("id", "==", id).limit(10).get()).docs) {
90
+ const request = requestOf(d.data(), d.id, way);
91
+ if (request)
92
+ return { key: d.id, request };
93
+ }
94
+ return null;
95
+ },
96
+ async finish(key, account) {
97
+ const ref = requestsCol().doc(key);
98
+ return db.runTransaction(async (tx) => {
99
+ const cur = requestOf((await tx.get(ref)).data(), key, way);
100
+ if (!cur || cur.status !== "waiting")
101
+ return false;
102
+ tx.set(ref, { status: "signedin", account }, { merge: true });
103
+ return true;
104
+ });
105
+ },
106
+ });
107
+ }
108
+ async function deleteAll(docs) {
109
+ for (let i = 0; i < docs.length; i += BATCH) {
110
+ const b = db.batch();
111
+ for (const d of docs.slice(i, i + BATCH))
112
+ b.delete(d.ref);
113
+ await b.commit();
114
+ }
115
+ return docs.length;
116
+ }
117
+ return {
118
+ requests: (way) => {
119
+ const s = stores.get(way);
120
+ if (!s)
121
+ throw new Error(`no such way: ${String(way)}`);
122
+ return s;
123
+ },
124
+ async deliver(way, key, session) {
125
+ const ref = requestsCol().doc(key);
126
+ const sref = sessionsCol().doc(session.idHash);
127
+ return db.runTransaction(async (tx) => {
128
+ const raw = (await tx.get(ref)).data();
129
+ const cur = requestOf(raw, key, way);
130
+ await tx.get(sref);
131
+ if (!cur || cur.status !== "signedin" || raw?.delivered === true)
132
+ return false;
133
+ tx.set(ref, { delivered: true }, { merge: true });
134
+ tx.set(sref, sessionData(session));
135
+ return true;
136
+ });
137
+ },
138
+ async session(idHash, now) {
139
+ const d = (await sessionsCol().doc(idHash).get()).data();
140
+ if (!d || d.scope !== "admin" || typeof d.address !== "string")
141
+ return null;
142
+ const expiresAt = num(d.expiresAt);
143
+ if (expiresAt === undefined || expiresAt <= now)
144
+ return null;
145
+ return { idHash, address: d.address, createdAt: num(d.createdAt) ?? 0, expiresAt, via: String(d.via ?? "") };
146
+ },
147
+ async endSession(idHash) {
148
+ const ref = sessionsCol().doc(idHash);
149
+ await db.runTransaction(async (tx) => {
150
+ const d = (await tx.get(ref)).data();
151
+ if (d && d.scope === "admin")
152
+ tx.delete(ref);
153
+ });
154
+ },
155
+ async signOutEverywhere() {
156
+ // 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
157
+ // delivery after it finds its request delivered (its transaction runs again on the changed document) and writes none.
158
+ const pending = (await requestsCol().where("scope", "==", "admin").get()).docs.filter((d) => d.data().delivered !== true);
159
+ for (let i = 0; i < pending.length; i += BATCH) {
160
+ const b = db.batch();
161
+ for (const d of pending.slice(i, i + BATCH))
162
+ b.set(d.ref, { delivered: true, expiresAt: 0 }, { merge: true });
163
+ await b.commit();
164
+ }
165
+ return deleteAll((await sessionsCol().where("scope", "==", "admin").get()).docs);
166
+ },
167
+ async recordLogin(login) {
168
+ const old = (await loginsCol().where("at", "<", login.at - ADMIN_LOGIN_KEEP_MS).limit(BATCH).get()).docs;
169
+ const b = db.batch();
170
+ 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) });
171
+ for (const d of old)
172
+ b.delete(d.ref);
173
+ await b.commit();
174
+ },
175
+ async recentLogins(limit) {
176
+ const n = Math.min(Math.max(Math.floor(Number(limit)) || 0, 0), 50);
177
+ if (!n)
178
+ return [];
179
+ // Newest first; two at the same time come in the order of their ids (the time, then random letters).
180
+ const docs = (await loginsCol().orderBy("at", "desc").limit(n).get()).docs;
181
+ return docs.map((d) => {
182
+ const v = d.data();
183
+ return { at: num(v.at) ?? 0, country: text(v.country) ?? null, browser: text(v.browser) ?? "Unknown browser" };
184
+ });
185
+ },
186
+ async clean(now) {
187
+ const old = (await requestsCol().where("expiresAt", "<", now - 2 * 3_600_000).limit(BATCH).get()).docs.filter((d) => d.data().scope === "admin");
188
+ const ended = (await sessionsCol().where("expiresAt", "<", now).limit(BATCH).get()).docs.filter((d) => d.data().scope === "admin");
189
+ const logins = (await loginsCol().where("at", "<", now - ADMIN_LOGIN_KEEP_MS).limit(BATCH).get()).docs;
190
+ const used = (await onceCol().where("until", "<", now + 1).limit(BATCH).get()).docs;
191
+ await deleteAll([...old, ...ended, ...logins, ...used]);
192
+ },
193
+ async createSession(session) {
194
+ const ref = sessionsCol().doc(session.idHash);
195
+ await db.runTransaction(async (tx) => {
196
+ if ((await tx.get(ref)).exists)
197
+ throw new Error("a session with this id exists");
198
+ tx.set(ref, sessionData(session));
199
+ });
200
+ },
201
+ async linkPasskey(owner, address, at) {
202
+ const ref = passkeysCol().doc(owner);
203
+ return db.runTransaction(async (tx) => {
204
+ const d = (await tx.get(ref)).data();
205
+ const list = d && d.owner === owner ? linksOf(d) : [];
206
+ if (list.some((p) => p.address === address))
207
+ return "exists";
208
+ if (list.length >= ADMIN_PASSKEY_LIMIT)
209
+ return "full";
210
+ tx.set(ref, { owner, passkeys: [...list, { address, createdAt: at }] });
211
+ return "linked";
212
+ });
213
+ },
214
+ async unlinkPasskey(owner, address) {
215
+ const ref = passkeysCol().doc(owner);
216
+ const had = await db.runTransaction(async (tx) => {
217
+ const d = (await tx.get(ref)).data();
218
+ const list = d && d.owner === owner ? linksOf(d) : [];
219
+ if (!list.some((p) => p.address === address))
220
+ return false;
221
+ tx.set(ref, { owner, passkeys: list.filter((p) => p.address !== address) });
222
+ return true;
223
+ });
224
+ // The passkey's admin sessions end with its link (after it: a session read in between finds no link and is refused anyway).
225
+ await deleteAll((await sessionsCol().where("address", "==", address).get()).docs.filter((x) => x.data().scope === "admin" && x.data().via === "passkey"));
226
+ return had;
227
+ },
228
+ async passkeys(owner) {
229
+ const d = (await passkeysCol().doc(owner).get()).data();
230
+ return d && d.owner === owner ? linksOf(d) : [];
231
+ },
232
+ async linked(owner, address) {
233
+ const d = (await passkeysCol().doc(owner).get()).data();
234
+ return Boolean(d && d.owner === owner && linksOf(d).some((p) => p.address === address));
235
+ },
236
+ async useOnce(key, ttlMs) {
237
+ const ref = onceCol().doc(key);
238
+ return db.runTransaction(async (tx) => {
239
+ const now = Date.now();
240
+ const d = (await tx.get(ref)).data();
241
+ if (d && (num(d.until) ?? Infinity) > now)
242
+ return false;
243
+ tx.set(ref, { until: now + ttlMs, deleteAt: new Date(now + ttlMs) });
244
+ return true;
245
+ });
246
+ },
247
+ };
248
+ }
@@ -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,18 @@
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 type AdminSigninConformanceOptions = {
12
+ /**
13
+ * The owner's passkeys (the store's optional passkey methods). true: the cases run and the store must have them. false: no passkey case. Not set: the
14
+ * cases are listed, and a store with no `linkPasskey` passes them with nothing to check (a store written for 0.11.0).
15
+ */
16
+ passkeys?: boolean;
17
+ };
18
+ export declare function adminSigninStoreConformance(make: () => Promise<AdminSigninHarness>, options?: AdminSigninConformanceOptions): AdminSigninConformanceCase[];