@nano133/nanoterra 0.11.0 → 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.
- package/CHANGELOG.md +26 -0
- package/README.md +84 -11
- package/ai/SPEC.md +11 -4
- package/dist/admin/guard.d.ts +9 -1
- package/dist/admin/guard.js +12 -4
- package/dist/admin/panel/card.d.ts +5 -0
- package/dist/admin/panel/card.js +3 -0
- package/dist/admin/panel/icons.d.ts +9 -0
- package/dist/admin/panel/icons.js +1 -0
- package/dist/admin/panel/index.d.ts +1 -1
- package/dist/admin/panel/mount.d.ts +7 -0
- package/dist/admin/panel/mount.js +4 -1
- package/dist/admin-signin/adapters/d1.d.ts +3 -1
- package/dist/admin-signin/adapters/d1.js +58 -3
- package/dist/admin-signin/adapters/firestore.d.ts +5 -2
- package/dist/admin-signin/adapters/firestore.js +74 -5
- package/dist/admin-signin/conformance.d.ts +8 -1
- package/dist/admin-signin/conformance.js +88 -3
- package/dist/admin-signin/handler.d.ts +7 -0
- package/dist/admin-signin/handler.js +134 -6
- package/dist/admin-signin/index.d.ts +2 -2
- package/dist/admin-signin/index.js +3 -2
- package/dist/admin-signin/memory.d.ts +6 -2
- package/dist/admin-signin/memory.js +47 -1
- package/dist/admin-signin/panel/index.d.ts +3 -0
- package/dist/admin-signin/panel/index.js +4 -2
- package/dist/admin-signin/panel/passkeys.d.ts +43 -0
- package/dist/admin-signin/panel/passkeys.js +202 -0
- package/dist/admin-signin/panel/view.d.ts +12 -0
- package/dist/admin-signin/panel/view.js +48 -1
- package/dist/admin-signin/types.d.ts +57 -7
- package/dist/admin-signin/types.js +20 -3
- package/package.json +1 -1
- package/src/admin/guard.ts +19 -5
- package/src/admin/panel/card.ts +5 -0
- package/src/admin/panel/icons.ts +1 -0
- package/src/admin/panel/index.ts +1 -1
- package/src/admin/panel/mount.ts +11 -1
- package/src/admin/panel/style.css +44 -0
- package/src/admin-signin/adapters/d1.sql +20 -3
- package/src/admin-signin/adapters/d1.ts +58 -5
- package/src/admin-signin/adapters/firestore.ts +73 -6
- package/src/admin-signin/conformance.ts +99 -3
- package/src/admin-signin/handler.ts +131 -4
- package/src/admin-signin/index.ts +7 -2
- package/src/admin-signin/memory.ts +43 -2
- package/src/admin-signin/panel/index.ts +6 -2
- package/src/admin-signin/panel/passkeys.ts +245 -0
- package/src/admin-signin/panel/view.ts +63 -1
- package/src/admin-signin/types.ts +62 -11
- package/src/player-signin/panel/style.css +3 -2
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
-- The admin sign-in tables of "@nano133/nanoterra/admin-signin/d1". Add this file to the game's migrations (for example
|
|
2
|
-
-- migrations/0009_admin_signin.sql) and run `wrangler d1 migrations apply`. The adapter reads and writes only these
|
|
2
|
+
-- migrations/0009_admin_signin.sql) and run `wrangler d1 migrations apply`. The adapter reads and writes only these five tables, and only the
|
|
3
3
|
-- ADMIN rows of the first two (scope = 'admin').
|
|
4
4
|
--
|
|
5
|
-
-- The game starter has
|
|
6
|
-
--
|
|
5
|
+
-- The game starter has the first three already (migrations 0001, 0002, 0003, 0005 and 0006), and `IF NOT EXISTS` leaves its tables and rows as they are if
|
|
6
|
+
-- it runs this file anyway. The last two (since 0.12.0) are the owner's passkeys: a game that turns them on (`createAdminSignin({ passkeys: true })`) and
|
|
7
|
+
-- has the first three adds only those two (the starter: migrations/0009_admin_passkeys.sql, the two CREATE TABLE statements at the end of this file). A
|
|
8
|
+
-- game that has the player sign-in's d1.sql has the first two: this file adds the other three.
|
|
7
9
|
-- Times are milliseconds since 1970 (Date.now()).
|
|
8
10
|
|
|
9
11
|
-- Wallet sign-in requests (the player's and the admin's: `scope` says whose). An admin request lives 2 minutes; the clean-up deletes it 2 hours after it ends.
|
|
@@ -43,3 +45,18 @@ CREATE TABLE IF NOT EXISTS admin_logins (
|
|
|
43
45
|
browser TEXT NOT NULL -- a short name from fixed lists: "Chrome on macOS"
|
|
44
46
|
);
|
|
45
47
|
CREATE INDEX IF NOT EXISTS admin_logins_by_time ON admin_logins (at);
|
|
48
|
+
|
|
49
|
+
-- The owner's passkeys (since 0.12.0): each passkey linked to the game's own address, at most 10 for an owner. Only a live WALLET admin session of the owner
|
|
50
|
+
-- writes a link; a link opens /admin only while it is here and its owner is the game's address NOW.
|
|
51
|
+
CREATE TABLE IF NOT EXISTS admin_passkeys (
|
|
52
|
+
owner TEXT NOT NULL, -- the game's own address when the link was made
|
|
53
|
+
address TEXT NOT NULL, -- the passkey purse's address
|
|
54
|
+
created_at INTEGER NOT NULL, -- ms since 1970
|
|
55
|
+
PRIMARY KEY (owner, address)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
-- The passkeys' used signatures (the admin's own, apart from the player's signin_once): the hash of each signed text, so one signature counts once.
|
|
59
|
+
CREATE TABLE IF NOT EXISTS admin_once (
|
|
60
|
+
key TEXT PRIMARY KEY,
|
|
61
|
+
until INTEGER NOT NULL -- ms since 1970; the clean-up deletes the row after it
|
|
62
|
+
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
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
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`).
|
|
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.
|
|
4
5
|
//
|
|
5
6
|
// import { createD1AdminSignin } from "@nano133/nanoterra/admin-signin/d1";
|
|
6
7
|
// const store = createD1AdminSignin(env.DB);
|
|
@@ -11,12 +12,12 @@
|
|
|
11
12
|
// "Sign out everywhere" is the same two statements as the admin store's (`createD1Store(db, { logins: true })`): either one may run it.
|
|
12
13
|
|
|
13
14
|
import type { D1Like } from "../../admin/adapters/d1.js";
|
|
14
|
-
import { ADMIN_LOGIN_KEEP_MS, ADMIN_WAYS, type AdminSessionRecord, type AdminSigninStore, type AdminWay, type RequestStoreLike, type SigninRequest } from "../types.js";
|
|
15
|
+
import { ADMIN_LOGIN_KEEP_MS, ADMIN_PASSKEY_LIMIT, ADMIN_WAYS, type AdminLinkResult, type AdminSessionRecord, type AdminSigninStore, type AdminWay, type RequestStoreLike, type SigninRequest } from "../types.js";
|
|
15
16
|
|
|
16
17
|
export type { D1Like, D1StatementLike } from "../../admin/adapters/d1.js";
|
|
17
18
|
|
|
18
19
|
/** The tables' names. Default: the game starter's. A game with other names changes d1.sql to match. */
|
|
19
|
-
export type D1AdminSigninTables = { requests?: string; sessions?: string; logins?: string };
|
|
20
|
+
export type D1AdminSigninTables = { requests?: string; sessions?: string; logins?: string; passkeys?: string; once?: string };
|
|
20
21
|
|
|
21
22
|
type Row = { id: string; rid: string | null; starter: string; status: string; expires_at: number; nonce: string | null; issued_at: number | null; label: string | null; account: string | null };
|
|
22
23
|
|
|
@@ -34,8 +35,17 @@ const toRequest = (r: Row): SigninRequest => ({
|
|
|
34
35
|
const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
|
|
35
36
|
const changes = (res: unknown): number => Number((res as { meta?: { changes?: number } } | undefined)?.meta?.changes ?? 0);
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
/** True for D1's (and SQLite's) "no such table": a game that has not added the passkeys' tables. */
|
|
39
|
+
const noTable = (e: unknown) => /no such table/i.test(String((e as { message?: unknown } | null)?.message ?? e));
|
|
40
|
+
|
|
41
|
+
export function createD1AdminSignin(db: D1Like, tables: D1AdminSigninTables = {}): Required<AdminSigninStore> {
|
|
42
|
+
const T = {
|
|
43
|
+
requests: tables.requests ?? "signin_requests",
|
|
44
|
+
sessions: tables.sessions ?? "sessions",
|
|
45
|
+
logins: tables.logins ?? "admin_logins",
|
|
46
|
+
passkeys: tables.passkeys ?? "admin_passkeys",
|
|
47
|
+
once: tables.once ?? "admin_once",
|
|
48
|
+
};
|
|
39
49
|
for (const n of Object.values(T)) if (!NAME.test(n)) throw new Error(`not a table name: ${n}`);
|
|
40
50
|
const COLS = "id, rid, starter, status, expires_at, nonce, issued_at, label, account";
|
|
41
51
|
|
|
@@ -112,6 +122,49 @@ export function createD1AdminSignin(db: D1Like, tables: D1AdminSigninTables = {}
|
|
|
112
122
|
db.prepare(`DELETE FROM ${T.sessions} WHERE expires_at < ? AND scope = 'admin'`).bind(now),
|
|
113
123
|
db.prepare(`DELETE FROM ${T.logins} WHERE at < ?`).bind(now - ADMIN_LOGIN_KEEP_MS),
|
|
114
124
|
]);
|
|
125
|
+
// The used passkey signatures, on their own: a game without the passkeys' tables (they came in 0.12.0) still cleans the rest.
|
|
126
|
+
try {
|
|
127
|
+
await db.prepare(`DELETE FROM ${T.once} WHERE until <= ?`).bind(now).run();
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (!noTable(e)) throw e;
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
async createSession(s) {
|
|
133
|
+
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();
|
|
134
|
+
},
|
|
135
|
+
async linkPasskey(owner, address, at): Promise<AdminLinkResult> {
|
|
136
|
+
// ONE statement holds the limit: the row is written only while the owner has fewer than the limit, and never twice.
|
|
137
|
+
const res = await db
|
|
138
|
+
.prepare(`INSERT INTO ${T.passkeys} (owner, address, created_at) SELECT ?, ?, ? WHERE (SELECT COUNT(*) FROM ${T.passkeys} WHERE owner = ?) < ? ON CONFLICT (owner, address) DO NOTHING`)
|
|
139
|
+
.bind(owner, address, at, owner, ADMIN_PASSKEY_LIMIT)
|
|
140
|
+
.run();
|
|
141
|
+
if (changes(res) === 1) return "linked";
|
|
142
|
+
const had = await db.prepare(`SELECT 1 AS one FROM ${T.passkeys} WHERE owner = ? AND address = ?`).bind(owner, address).first<{ one: number }>();
|
|
143
|
+
return had ? "exists" : "full";
|
|
144
|
+
},
|
|
145
|
+
async unlinkPasskey(owner, address) {
|
|
146
|
+
// The link, and the passkey's admin sessions with it.
|
|
147
|
+
const done = await db.batch([
|
|
148
|
+
db.prepare(`DELETE FROM ${T.passkeys} WHERE owner = ? AND address = ?`).bind(owner, address),
|
|
149
|
+
db.prepare(`DELETE FROM ${T.sessions} WHERE scope = 'admin' AND via = 'passkey' AND address = ?`).bind(address),
|
|
150
|
+
]);
|
|
151
|
+
return changes(done[0]) > 0;
|
|
152
|
+
},
|
|
153
|
+
async passkeys(owner) {
|
|
154
|
+
const rows = (await db.prepare(`SELECT address, created_at FROM ${T.passkeys} WHERE owner = ? ORDER BY created_at, address`).bind(owner).all<{ address: string; created_at: number }>()).results;
|
|
155
|
+
return rows.map((r) => ({ address: String(r.address), createdAt: Number(r.created_at) }));
|
|
156
|
+
},
|
|
157
|
+
async linked(owner, address) {
|
|
158
|
+
return (await db.prepare(`SELECT 1 AS one FROM ${T.passkeys} WHERE owner = ? AND address = ?`).bind(owner, address).first<{ one: number }>()) !== null;
|
|
159
|
+
},
|
|
160
|
+
async useOnce(key, ttlMs) {
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
// A row past its time may be taken again (its signature's window is long over); a live one may not.
|
|
163
|
+
const res = await db
|
|
164
|
+
.prepare(`INSERT INTO ${T.once} (key, until) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET until = excluded.until WHERE ${T.once}.until <= ?`)
|
|
165
|
+
.bind(key, now + ttlMs, now)
|
|
166
|
+
.run();
|
|
167
|
+
return changes(res) === 1;
|
|
115
168
|
},
|
|
116
169
|
};
|
|
117
170
|
}
|
|
@@ -9,15 +9,18 @@
|
|
|
9
9
|
// signin_requests/{key} { id, way, starter, status, expiresAt, nonce?, issuedAt?, label?, account?, delivered, scope: "admin", deleteAt }
|
|
10
10
|
// sessions/{idHash} { address, createdAt, expiresAt, via, scope: "admin", deleteAt }
|
|
11
11
|
// admin_logins/{id} { at, country, browser, deleteAt }
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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`).
|
|
15
18
|
//
|
|
16
19
|
// The admin store for Firestore (`createFirestoreStore`) has no sign-in list and no "Sign out everywhere": give it this sign-in's,
|
|
17
20
|
// `store.recentLogins = signin.recentLogins` and `store.signOutEverywhere = signin.signOutEverywhere`.
|
|
18
21
|
|
|
19
22
|
import type { AdminLogin } from "../../admin/store.js";
|
|
20
|
-
import { ADMIN_LOGIN_KEEP_MS, ADMIN_WAYS, type AdminSessionRecord, type AdminSigninStore, type AdminWay, type RequestStoreLike, type SigninRequest } from "../types.js";
|
|
23
|
+
import { ADMIN_LOGIN_KEEP_MS, ADMIN_PASSKEY_LIMIT, ADMIN_WAYS, type AdminPasskey, type AdminSessionRecord, type AdminSigninStore, type AdminWay, type RequestStoreLike, type SigninRequest } from "../types.js";
|
|
21
24
|
|
|
22
25
|
export type AdminSigninDocSnap = { exists: boolean; data(): Record<string, unknown> | undefined };
|
|
23
26
|
export interface AdminSigninDocRef {
|
|
@@ -76,10 +79,24 @@ function requestOf(d: Record<string, unknown> | undefined, key: string, way: Adm
|
|
|
76
79
|
};
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
|
|
82
|
+
/** The links a passkeys document holds: only well-formed ones. */
|
|
83
|
+
function linksOf(d: Record<string, unknown> | undefined): AdminPasskey[] {
|
|
84
|
+
const list = Array.isArray(d?.passkeys) ? (d.passkeys as unknown[]) : [];
|
|
85
|
+
return list.flatMap((p) => {
|
|
86
|
+
const v = p as Record<string, unknown> | null;
|
|
87
|
+
const address = text(v?.address);
|
|
88
|
+
return address !== undefined ? [{ address, createdAt: num(v?.createdAt) ?? 0 }] : [];
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type FirestoreAdminSigninCollections = { requests?: string; sessions?: string; logins?: string; passkeys?: string; once?: string };
|
|
93
|
+
|
|
94
|
+
export function createFirestoreAdminSignin(db: FirestoreAdminSigninLike, collections: FirestoreAdminSigninCollections = {}): Required<AdminSigninStore> {
|
|
80
95
|
const requestsCol = () => db.collection(collections.requests ?? "signin_requests");
|
|
81
96
|
const sessionsCol = () => db.collection(collections.sessions ?? "sessions");
|
|
82
97
|
const loginsCol = () => db.collection(collections.logins ?? "admin_logins");
|
|
98
|
+
const passkeysCol = () => db.collection(collections.passkeys ?? "admin_passkeys");
|
|
99
|
+
const onceCol = () => db.collection(collections.once ?? "admin_once");
|
|
83
100
|
|
|
84
101
|
const sessionData = (s: AdminSessionRecord) => ({ address: s.address, createdAt: s.createdAt, expiresAt: s.expiresAt, via: s.via, scope: "admin", deleteAt: new Date(s.expiresAt) });
|
|
85
102
|
|
|
@@ -201,7 +218,57 @@ export function createFirestoreAdminSignin(db: FirestoreAdminSigninLike, collect
|
|
|
201
218
|
const old = (await requestsCol().where("expiresAt", "<", now - 2 * 3_600_000).limit(BATCH).get()).docs.filter((d) => d.data().scope === "admin");
|
|
202
219
|
const ended = (await sessionsCol().where("expiresAt", "<", now).limit(BATCH).get()).docs.filter((d) => d.data().scope === "admin");
|
|
203
220
|
const logins = (await loginsCol().where("at", "<", now - ADMIN_LOGIN_KEEP_MS).limit(BATCH).get()).docs;
|
|
204
|
-
await
|
|
221
|
+
const used = (await onceCol().where("until", "<", now + 1).limit(BATCH).get()).docs;
|
|
222
|
+
await deleteAll([...old, ...ended, ...logins, ...used]);
|
|
223
|
+
},
|
|
224
|
+
async createSession(session) {
|
|
225
|
+
const ref = sessionsCol().doc(session.idHash);
|
|
226
|
+
await db.runTransaction(async (tx) => {
|
|
227
|
+
if ((await tx.get(ref)).exists) throw new Error("a session with this id exists");
|
|
228
|
+
tx.set(ref, sessionData(session));
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
async linkPasskey(owner, address, at) {
|
|
232
|
+
const ref = passkeysCol().doc(owner);
|
|
233
|
+
return db.runTransaction(async (tx) => {
|
|
234
|
+
const d = (await tx.get(ref)).data();
|
|
235
|
+
const list = d && d.owner === owner ? linksOf(d) : [];
|
|
236
|
+
if (list.some((p) => p.address === address)) return "exists" as const;
|
|
237
|
+
if (list.length >= ADMIN_PASSKEY_LIMIT) return "full" as const;
|
|
238
|
+
tx.set(ref, { owner, passkeys: [...list, { address, createdAt: at }] });
|
|
239
|
+
return "linked" as const;
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
async unlinkPasskey(owner, address) {
|
|
243
|
+
const ref = passkeysCol().doc(owner);
|
|
244
|
+
const had = await db.runTransaction(async (tx) => {
|
|
245
|
+
const d = (await tx.get(ref)).data();
|
|
246
|
+
const list = d && d.owner === owner ? linksOf(d) : [];
|
|
247
|
+
if (!list.some((p) => p.address === address)) return false;
|
|
248
|
+
tx.set(ref, { owner, passkeys: list.filter((p) => p.address !== address) });
|
|
249
|
+
return true;
|
|
250
|
+
});
|
|
251
|
+
// The passkey's admin sessions end with its link (after it: a session read in between finds no link and is refused anyway).
|
|
252
|
+
await deleteAll((await sessionsCol().where("address", "==", address).get()).docs.filter((x) => x.data().scope === "admin" && x.data().via === "passkey"));
|
|
253
|
+
return had;
|
|
254
|
+
},
|
|
255
|
+
async passkeys(owner) {
|
|
256
|
+
const d = (await passkeysCol().doc(owner).get()).data();
|
|
257
|
+
return d && d.owner === owner ? linksOf(d) : [];
|
|
258
|
+
},
|
|
259
|
+
async linked(owner, address) {
|
|
260
|
+
const d = (await passkeysCol().doc(owner).get()).data();
|
|
261
|
+
return Boolean(d && d.owner === owner && linksOf(d).some((p) => p.address === address));
|
|
262
|
+
},
|
|
263
|
+
async useOnce(key, ttlMs) {
|
|
264
|
+
const ref = onceCol().doc(key);
|
|
265
|
+
return db.runTransaction(async (tx) => {
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
const d = (await tx.get(ref)).data();
|
|
268
|
+
if (d && (num(d.until) ?? Infinity) > now) return false;
|
|
269
|
+
tx.set(ref, { until: now + ttlMs, deleteAt: new Date(now + ttlMs) });
|
|
270
|
+
return true;
|
|
271
|
+
});
|
|
205
272
|
},
|
|
206
273
|
};
|
|
207
274
|
}
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
//
|
|
7
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
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
|
|
9
|
+
// sign-in list keeps three things for 30 days; and (since 0.12.0) the owner's passkeys: a link is written once, at most 10 for an owner even when many come
|
|
10
|
+
// at once, a removal ends the passkey's sessions, and a used signature counts once. (The whole chain, with the player's sign-in beside it, is `adminSigninConformance` in
|
|
10
11
|
// "@nano133/nanoterra/admin/conformance".)
|
|
11
12
|
|
|
12
13
|
import type { AdminSessionRecord, AdminSigninStore, SigninRequest } from "./types.js";
|
|
13
|
-
import { ADMIN_LOGIN_KEEP_MS } from "./types.js";
|
|
14
|
+
import { ADMIN_LOGIN_KEEP_MS, ADMIN_PASSKEY_LIMIT } from "./types.js";
|
|
14
15
|
|
|
15
16
|
export type AdminSigninHarness = {
|
|
16
17
|
store: AdminSigninStore;
|
|
@@ -38,9 +39,31 @@ function request(at: number, extra: Partial<SigninRequest> = {}): { key: string;
|
|
|
38
39
|
}
|
|
39
40
|
const session = (at: number, n: number, via = "wallet"): AdminSessionRecord => ({ idHash: hex(1_000_000 + n + serial++), address: OWNER, createdAt: at, expiresAt: at + 86_400_000, via });
|
|
40
41
|
|
|
41
|
-
export
|
|
42
|
+
export type AdminSigninConformanceOptions = {
|
|
43
|
+
/**
|
|
44
|
+
* 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
|
|
45
|
+
* cases are listed, and a store with no `linkPasskey` passes them with nothing to check (a store written for 0.11.0).
|
|
46
|
+
*/
|
|
47
|
+
passkeys?: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type PasskeyStore = Required<AdminSigninStore>;
|
|
51
|
+
|
|
52
|
+
export function adminSigninStoreConformance(make: () => Promise<AdminSigninHarness>, options: AdminSigninConformanceOptions = {}): AdminSigninConformanceCase[] {
|
|
42
53
|
const cases: AdminSigninConformanceCase[] = [];
|
|
43
54
|
const add = (name: string, run: (h: AdminSigninHarness, now: number) => Promise<void>) => cases.push({ name, run: async () => run(await make(), Date.now()) });
|
|
55
|
+
/** A passkey case: it runs only on a store with the passkey methods (required with `passkeys: true`). */
|
|
56
|
+
const addPasskey = (name: string, run: (store: PasskeyStore, now: number, h: AdminSigninHarness) => Promise<void>) => {
|
|
57
|
+
if (options.passkeys === false) return;
|
|
58
|
+
add(`passkeys: ${name}`, async (h, now) => {
|
|
59
|
+
const has = ["createSession", "linkPasskey", "unlinkPasskey", "passkeys", "linked", "useOnce"].every((m) => typeof (h.store as unknown as Record<string, unknown>)[m] === "function");
|
|
60
|
+
if (!has) {
|
|
61
|
+
check(options.passkeys !== true, "the store has no passkey methods (createSession, linkPasskey, unlinkPasskey, passkeys, linked, useOnce)");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
await run(h.store as PasskeyStore, now, h);
|
|
65
|
+
});
|
|
66
|
+
};
|
|
44
67
|
|
|
45
68
|
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) => {
|
|
46
69
|
const { key, r } = request(now, { label: "ADMIN of game.test sign-in ABCD-EFGH from US" });
|
|
@@ -208,5 +231,78 @@ export function adminSigninStoreConformance(make: () => Promise<AdminSigninHarne
|
|
|
208
231
|
check((await store.recentLogins(10)).length === 0, "the sign-in row past 30 days went");
|
|
209
232
|
});
|
|
210
233
|
|
|
234
|
+
addPasskey("a link is written once and read back exactly, oldest first, for its owner only", async (store, now) => {
|
|
235
|
+
const a = who("pka");
|
|
236
|
+
const b = who("pkb");
|
|
237
|
+
check((await store.passkeys(OWNER)).length === 0, "a new store lists no passkey");
|
|
238
|
+
check(!(await store.linked(OWNER, a)), "nothing is linked yet");
|
|
239
|
+
check((await store.linkPasskey(OWNER, a, now - 1000)) === "linked", "the first link");
|
|
240
|
+
check((await store.linkPasskey(OWNER, b, now)) === "linked", "a second passkey");
|
|
241
|
+
check((await store.linkPasskey(OWNER, a, now + 5)) === "exists", "the same passkey again is 'exists'");
|
|
242
|
+
const list = await store.passkeys(OWNER);
|
|
243
|
+
check(list.length === 2 && list[0].address === a && list[0].createdAt === now - 1000 && list[1].address === b, `the list: ${JSON.stringify(list)}`);
|
|
244
|
+
check(list.every((p) => Object.keys(p).sort().join() === "address,createdAt"), `a passkey carries only address and createdAt: ${JSON.stringify(list[0])}`);
|
|
245
|
+
check(await store.linked(OWNER, a), "linked");
|
|
246
|
+
check(!(await store.linked(who("oth"), a)), "another owner has no link to it");
|
|
247
|
+
check(!(await store.linked(OWNER.toUpperCase(), a)) && !(await store.linked(OWNER, a.toUpperCase())) && !(await store.linked(` ${OWNER}`, a)), "only the exact texts are linked");
|
|
248
|
+
check((await store.passkeys(who("oth"))).length === 0, "another owner lists none");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
addPasskey(`at most ${ADMIN_PASSKEY_LIMIT} for an owner, even when many links come at once; another owner has its own ${ADMIN_PASSKEY_LIMIT}`, async (store, now) => {
|
|
252
|
+
const many = Array.from({ length: ADMIN_PASSKEY_LIMIT + 4 }, (_, i) => who(`m${i}`));
|
|
253
|
+
const got = await Promise.all(many.map((a, i) => store.linkPasskey(OWNER, a, now + i)));
|
|
254
|
+
check(got.filter((r) => r === "linked").length === ADMIN_PASSKEY_LIMIT && got.filter((r) => r === "full").length === 4, `results: ${JSON.stringify(got)}`);
|
|
255
|
+
check((await store.passkeys(OWNER)).length === ADMIN_PASSKEY_LIMIT, "the list holds the limit");
|
|
256
|
+
check((await store.linkPasskey(OWNER, who("late"), now)) === "full", "full");
|
|
257
|
+
const linked = (await store.passkeys(OWNER))[0].address;
|
|
258
|
+
check((await store.linkPasskey(OWNER, linked, now)) === "exists", "a passkey that is there is 'exists', not 'full'");
|
|
259
|
+
check((await store.linkPasskey(who("oth"), who("late"), now)) === "linked", "another owner is not full");
|
|
260
|
+
check(await store.unlinkPasskey(OWNER, linked), "a removal");
|
|
261
|
+
check((await store.linkPasskey(OWNER, who("late"), now)) === "linked", "a removal makes room for one");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
addPasskey("a removal ends the link and every admin session of that passkey, and nothing else", async (store, now) => {
|
|
265
|
+
const a = who("rma");
|
|
266
|
+
const b = who("rmb");
|
|
267
|
+
await store.linkPasskey(OWNER, a, now);
|
|
268
|
+
await store.linkPasskey(OWNER, b, now);
|
|
269
|
+
const sa = { ...session(now, 60, "passkey"), address: a };
|
|
270
|
+
const sb = { ...session(now, 61, "passkey"), address: b };
|
|
271
|
+
const owner = session(now, 62, "wallet");
|
|
272
|
+
for (const s of [sa, sb, owner]) await store.createSession(s);
|
|
273
|
+
const read = await store.session(sa.idHash, now);
|
|
274
|
+
check(read?.address === a && read.via === "passkey" && read.createdAt === now && read.expiresAt === sa.expiresAt, `a passkey session reads back: ${JSON.stringify(read)}`);
|
|
275
|
+
check(await store.unlinkPasskey(OWNER, a), "the removal says it removed one");
|
|
276
|
+
check(!(await store.unlinkPasskey(OWNER, a)), "a second removal says false");
|
|
277
|
+
check(!(await store.linked(OWNER, a)) && (await store.linked(OWNER, b)), "only that link went");
|
|
278
|
+
check((await store.session(sa.idHash, now)) === null, "the passkey's session ended with its link");
|
|
279
|
+
check((await store.session(sb.idHash, now)) !== null && (await store.session(owner.idHash, now)) !== null, "the other passkey's session and the wallet's session stay");
|
|
280
|
+
let threw = false;
|
|
281
|
+
try {
|
|
282
|
+
await store.createSession(sb);
|
|
283
|
+
} catch {
|
|
284
|
+
threw = true;
|
|
285
|
+
}
|
|
286
|
+
check(threw, "a second session with the same hash is refused");
|
|
287
|
+
check((await store.signOutEverywhere()) === 2, "Sign out everywhere ends the passkey sessions too");
|
|
288
|
+
check((await store.session(sb.idHash, now)) === null, "and the passkey's session is gone");
|
|
289
|
+
check(await store.linked(OWNER, b), "Sign out everywhere keeps the links (a removal ends one)");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
addPasskey("a used signature counts once, even twice at the same moment; after its time, or after clean, it may be used again", async (store) => {
|
|
293
|
+
const k = hex(31337);
|
|
294
|
+
check(await store.useOnce(k, 60_000), "the first use");
|
|
295
|
+
check(!(await store.useOnce(k, 60_000)), "the second use is refused");
|
|
296
|
+
const k2 = hex(31338);
|
|
297
|
+
const both = await Promise.all([store.useOnce(k2, 60_000), store.useOnce(k2, 60_000)]);
|
|
298
|
+
check(both.filter(Boolean).length === 1, `two at once: ${JSON.stringify(both)}`);
|
|
299
|
+
const k3 = hex(31339);
|
|
300
|
+
check(await store.useOnce(k3, 1), "a short one");
|
|
301
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
302
|
+
check(await store.useOnce(k3, 60_000), "past its time it may be used again");
|
|
303
|
+
await store.clean(Date.now() + 120_000);
|
|
304
|
+
check(await store.useOnce(k, 60_000), "clean dropped the used row past its time");
|
|
305
|
+
});
|
|
306
|
+
|
|
211
307
|
return cases;
|
|
212
308
|
}
|
|
@@ -10,6 +10,19 @@
|
|
|
10
10
|
// POST {base}/check the browser asks every 2 seconds: { id, way } → { status: "waiting" | "expired" | "elsewhere" | "used" | "not_owner" | "signedin", address? }
|
|
11
11
|
// POST {base}/signout ends this browser's admin login only: { ok: true }
|
|
12
12
|
//
|
|
13
|
+
// With `passkeys: true` (since 0.12.0), the owner's linked passkeys (a passkey is only a key to /admin; the money stays in the owner's wallet):
|
|
14
|
+
//
|
|
15
|
+
// POST {base}/passkey/link { address, at, signature }: ONLY with a live WALLET admin session of the owner, and the passkey's own signature of
|
|
16
|
+
// "Link this passkey to the ADMIN of <host>: sign in as <address> at <ms>": both sides prove their key → { ok, passkeys }
|
|
17
|
+
// POST {base}/passkey/unlink { address }: only with a live wallet admin session of the owner; the passkey's admin sessions end too → { ok, removed, passkeys }
|
|
18
|
+
// POST {base}/passkeys the list, for any session the guard accepts → { passkeys, limit, via, canChange, you }
|
|
19
|
+
// POST {base}/passkey { address, at, signature, turnstile? }: the passkey's signature of "ADMIN of <host>: sign in as <address> at <ms>" (never
|
|
20
|
+
// the host alone, which a player's passkey signs), refused unless the passkey is linked to the owner → a NEW admin session
|
|
21
|
+
// { via: "passkey" } of one day and the admin cookie
|
|
22
|
+
//
|
|
23
|
+
// Each passkey signature counts once (the admin's own table) and within 5 minutes. A passkey session opens /admin only while its link to the CURRENT owner
|
|
24
|
+
// exists: `session(request)` reads the link on every request, so a removed link or a new owner address ends it at the next request.
|
|
25
|
+
//
|
|
13
26
|
// `base` is "/api/admin-signin" (ADMIN_SIGNIN_BASE). Berry Wallet and Nautilus sign a text that names the ADMIN: Berry signs the page `${origin}/admin`
|
|
14
27
|
// with NO query (Berry refused "/admin?from=US" with "Sign-in requires a public HTTPS website", device test 2026-10-27), Nautilus signs the label "ADMIN of
|
|
15
28
|
// <host> sign-in ABCD-EFGH from <country>" and shows "Sign in to the ADMIN of <host>. Started <time> UTC from <country>. If you did not start this, do not
|
|
@@ -29,6 +42,7 @@ import {
|
|
|
29
42
|
answerNanoSignin,
|
|
30
43
|
answerNautilus,
|
|
31
44
|
checkNanchat,
|
|
45
|
+
checkSignatureSignin,
|
|
32
46
|
checkNanoSignin,
|
|
33
47
|
checkNautilus,
|
|
34
48
|
nanchatNonce,
|
|
@@ -37,6 +51,7 @@ import {
|
|
|
37
51
|
nanoSigninRefusal,
|
|
38
52
|
nautilusReply,
|
|
39
53
|
readNanoSignin,
|
|
54
|
+
SigninError,
|
|
40
55
|
startNanchat,
|
|
41
56
|
startNanoSignin,
|
|
42
57
|
startNautilus,
|
|
@@ -47,13 +62,15 @@ import {
|
|
|
47
62
|
type NautilusOptions,
|
|
48
63
|
type RequestCheck,
|
|
49
64
|
} from "@nano133/signin";
|
|
50
|
-
import { ADMIN_SESSION_MS } from "../admin/guard.js";
|
|
65
|
+
import { ADMIN_SESSION_MS, adminAccess } from "../admin/guard.js";
|
|
51
66
|
import { fail, json, memoryLimiter, readLimited, sameOrigin } from "../admin/http.js";
|
|
52
67
|
import type { AdminLogin } from "../admin/store.js";
|
|
53
68
|
import { countryOf, shortBrowser } from "./browser.js";
|
|
54
69
|
import {
|
|
55
70
|
ADMIN_DELIVER_MS,
|
|
71
|
+
ADMIN_ONCE_MS,
|
|
56
72
|
ADMIN_PAGE,
|
|
73
|
+
ADMIN_PASSKEY_LIMIT,
|
|
57
74
|
ADMIN_REQUEST_MS,
|
|
58
75
|
ADMIN_SIGNIN_BASE,
|
|
59
76
|
ADMIN_SIGNIN_LIMITS,
|
|
@@ -61,6 +78,8 @@ import {
|
|
|
61
78
|
ADMIN_WAYS,
|
|
62
79
|
NANCHAT_REFUSAL,
|
|
63
80
|
TOKEN_PATTERN,
|
|
81
|
+
adminLinkDomain,
|
|
82
|
+
adminPasskeyDomain,
|
|
64
83
|
type AdminSigninSession,
|
|
65
84
|
type AdminSigninStore,
|
|
66
85
|
type AdminWay,
|
|
@@ -89,6 +108,13 @@ export type AdminSigninOptions = {
|
|
|
89
108
|
* game sign-in. Turn it on only when the owner accepts that; give the panel the same `nanchat: true`.
|
|
90
109
|
*/
|
|
91
110
|
nanchat?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* The owner's linked passkeys (since 0.12.0). Default false: no passkey route, and no passkey session opens /admin. With true, a live WALLET admin
|
|
113
|
+
* session of the owner may link up to 10 passkeys (each proves its key by signing), and a linked passkey's signature makes an admin login while its link to
|
|
114
|
+
* the current owner exists. The store needs the passkey methods (the package's stores have them; D1 needs the two tables at the end of d1.sql). Give the
|
|
115
|
+
* panel the same `passkeys: true` and the game's frozen passkey `scheme`.
|
|
116
|
+
*/
|
|
117
|
+
passkeys?: boolean;
|
|
92
118
|
/** The cookies' names. Default { session: "admin", starter: "starter-admin" } (the starter's). On https they get the __Host- prefix. */
|
|
93
119
|
cookies?: { session?: string; starter?: string };
|
|
94
120
|
/** The shared limiter of the starts: false means "too many". Default: a fixed window in this process's memory. */
|
|
@@ -156,6 +182,14 @@ function cookieHeader(req: Request, name: string, value: string, maxAgeSec: numb
|
|
|
156
182
|
/** A cookie's value: on https only the `__Host-` name (a sibling subdomain can set the plain one; it cannot set a `__Host-` one). */
|
|
157
183
|
const readCookie = (req: Request, name: string) => cookieValue(req, isHttps(req) ? `__Host-${name}` : name);
|
|
158
184
|
|
|
185
|
+
/** What the owner reads for a refused passkey signature. */
|
|
186
|
+
function passkeyWords(e: SigninError): string {
|
|
187
|
+
if (e.status === 409) return "That passkey signature was used already. Try again.";
|
|
188
|
+
if (e.status === 401) return "The passkey's signature does not match. Try again.";
|
|
189
|
+
if (/too old/.test(e.message)) return "That signature is too old. Check the clock of this device, then try again.";
|
|
190
|
+
return "That passkey signature could not be read. Try again.";
|
|
191
|
+
}
|
|
192
|
+
|
|
159
193
|
/** The limiters live at module level: a Worker makes the sign-in for each request, and a limiter made inside it would forget every call. */
|
|
160
194
|
const fallbackLimit = memoryLimiter();
|
|
161
195
|
const memory = memoryLimiter();
|
|
@@ -175,6 +209,13 @@ export function createAdminSignin(options: AdminSigninOptions): AdminSignin {
|
|
|
175
209
|
const limit = options.limit ?? fallbackLimit;
|
|
176
210
|
const siteAddress = owner && validAddress(owner) ? owner : EXAMPLE_ADDRESS;
|
|
177
211
|
const nanchatOn = options.nanchat === true;
|
|
212
|
+
const passkeysOn = options.passkeys === true;
|
|
213
|
+
const pk = store as Required<AdminSigninStore>;
|
|
214
|
+
if (passkeysOn) {
|
|
215
|
+
for (const m of ["createSession", "linkPasskey", "unlinkPasskey", "passkeys", "linked", "useOnce"] as const) {
|
|
216
|
+
if (typeof store[m] !== "function") throw new Error(`passkeys: true needs a store with ${m}() (the package's stores have it)`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
178
219
|
const report = (e: unknown) => {
|
|
179
220
|
try {
|
|
180
221
|
options.onError?.(e);
|
|
@@ -352,8 +393,87 @@ export function createAdminSignin(options: AdminSigninOptions): AdminSignin {
|
|
|
352
393
|
if (!token || !TOKEN_PATTERN.test(token)) return null;
|
|
353
394
|
// The scope is in the store's row (the adapter reads only admin rows) as well as in the hash: the two scopes stay apart in two ways.
|
|
354
395
|
const s = await store.session(await adminKey(token), at);
|
|
355
|
-
|
|
356
|
-
|
|
396
|
+
if (!s) return null;
|
|
397
|
+
if (s.via === "passkey") {
|
|
398
|
+
// A passkey's login counts only while its link to the CURRENT owner exists: read now, on every request. A removed link, a new owner address, or the
|
|
399
|
+
// passkeys turned off: no session at all (the page asks for a sign-in). `linkedTo` is set here only, and only after this check.
|
|
400
|
+
if (!passkeysOn || typeof owner !== "string" || !validAddress(owner)) return null;
|
|
401
|
+
return (await pk.linked(owner, s.address)) ? { address: s.address, createdAt: s.createdAt, via: "passkey", linkedTo: owner } : null;
|
|
402
|
+
}
|
|
403
|
+
// Only the words a login can carry are read; any other is "unknown", never a wallet login (a fail-open map would turn a stray value into a login that opens /admin).
|
|
404
|
+
return { address: s.address, createdAt: s.createdAt, via: s.via === "wallet" || s.via === "nanoterra" ? s.via : "unknown" };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---- the owner's passkeys ----
|
|
408
|
+
|
|
409
|
+
/** The host every passkey text names: `domain`, or this request's host. */
|
|
410
|
+
const hostOf = (req: Request) => options.domain ?? new URL(req.url).host;
|
|
411
|
+
|
|
412
|
+
/** The session the guard accepts now, or the refusal to send. `wallet`: only a wallet session of the owner (a link and a removal). */
|
|
413
|
+
async function ownerSession(req: Request, at: number, wallet: boolean): Promise<{ ok: true; session: AdminSigninSession } | { ok: false; res: Response }> {
|
|
414
|
+
const session = await sessionOf(req, at);
|
|
415
|
+
const access = adminAccess({ owner, session, now: at });
|
|
416
|
+
if (!access.ok) return { ok: false, res: fail(access.status, access.error, access.reason ? { reason: access.reason } : {}) };
|
|
417
|
+
if (wallet && session!.via !== "wallet") return { ok: false, res: fail(403, "Only a wallet sign-in can add or remove a passkey. Sign in with your wallet.", { reason: "wallet_only" }) };
|
|
418
|
+
return { ok: true, session: session! };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** A passkey's signature of `domain`'s text, checked once: its address, or the refusal to send. */
|
|
422
|
+
async function passkeySignature(b: Record<string, unknown>, domain: string, label: string, at: number): Promise<{ ok: true; address: string } | { ok: false; res: Response }> {
|
|
423
|
+
try {
|
|
424
|
+
const address = await checkSignatureSignin(
|
|
425
|
+
{ domain, useOnce: (k, ttl) => pk.useOnce(k, Math.max(ttl, ADMIN_ONCE_MS)), now: () => at, onceLabel: label },
|
|
426
|
+
{ address: b.address as string, at: Number(b.at), signature: b.signature as string },
|
|
427
|
+
);
|
|
428
|
+
return { ok: true, address };
|
|
429
|
+
} catch (e) {
|
|
430
|
+
if (e instanceof SigninError) return { ok: false, res: fail(e.status, passkeyWords(e)) };
|
|
431
|
+
throw e;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function passkeyList(session: AdminSigninSession): Promise<Record<string, unknown>> {
|
|
436
|
+
return { passkeys: await pk.passkeys(owner!), limit: ADMIN_PASSKEY_LIMIT, via: session.via, canChange: session.via === "wallet", you: session.address };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function link(req: Request, b: Record<string, unknown>, at: number): Promise<Response> {
|
|
440
|
+
const o = await ownerSession(req, at, true);
|
|
441
|
+
if (!o.ok) return o.res;
|
|
442
|
+
if (!(await limit(`signin|${await visitorOf(req)}`, ADMIN_SIGNIN_LIMITS.signin.limit, ADMIN_SIGNIN_LIMITS.signin.windowMs, at))) return fail(429, "Too many tries. Wait a few minutes.");
|
|
443
|
+
const sig = await passkeySignature(b, adminLinkDomain(hostOf(req)), "nanoterra admin passkey link v1", at);
|
|
444
|
+
if (!sig.ok) return sig.res;
|
|
445
|
+
const r = await pk.linkPasskey(owner!, sig.address, at);
|
|
446
|
+
if (r === "full") return fail(409, `You have ${ADMIN_PASSKEY_LIMIT} passkeys. Remove one first.`, { reason: "full" });
|
|
447
|
+
if (r === "exists") return fail(409, "This passkey is linked already.", { reason: "exists" });
|
|
448
|
+
return json({ ok: true, address: sig.address, ...(await passkeyList(o.session)) });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function unlink(req: Request, b: Record<string, unknown>, at: number): Promise<Response> {
|
|
452
|
+
const o = await ownerSession(req, at, true);
|
|
453
|
+
if (!o.ok) return o.res;
|
|
454
|
+
if (typeof b.address !== "string" || !validAddress(b.address)) return fail(400, "That is not a passkey's address.");
|
|
455
|
+
const removed = await pk.unlinkPasskey(owner!, b.address);
|
|
456
|
+
return json({ ok: true, removed, ...(await passkeyList(o.session)) });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function list(req: Request, at: number): Promise<Response> {
|
|
460
|
+
const o = await ownerSession(req, at, false);
|
|
461
|
+
if (!o.ok) return o.res;
|
|
462
|
+
return json(await passkeyList(o.session));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function passkeySignin(req: Request, b: Record<string, unknown>, at: number): Promise<Response> {
|
|
466
|
+
if (!(await limit(`signin|${await visitorOf(req)}`, ADMIN_SIGNIN_LIMITS.signin.limit, ADMIN_SIGNIN_LIMITS.signin.windowMs, at))) return fail(429, "Too many sign-ins. Wait a few minutes.");
|
|
467
|
+
if (!(await person(b.turnstile, req))) return fail(403, "The person check failed. Try again.");
|
|
468
|
+
if (typeof owner !== "string" || !validAddress(owner)) return fail(403, "Set the game's own Nano address first. Only that address can open this page.", { reason: "no_address" });
|
|
469
|
+
const sig = await passkeySignature(b, adminPasskeyDomain(hostOf(req)), "nanoterra admin passkey signin v1", at);
|
|
470
|
+
if (!sig.ok) return sig.res;
|
|
471
|
+
// The signature is checked first, so a stranger learns nothing of which passkeys are linked.
|
|
472
|
+
if (!(await pk.linked(owner, sig.address))) return fail(403, "This passkey is not linked to the game's address. Sign in with your wallet, then add this passkey in Settings.", { reason: "not_linked" });
|
|
473
|
+
const token = randomHex(32);
|
|
474
|
+
await pk.createSession({ idHash: await adminKey(token), address: sig.address, createdAt: at, expiresAt: at + ADMIN_SESSION_MS, via: "passkey" });
|
|
475
|
+
await store.recordLogin({ at, country: countryOf(req), browser: shortBrowser(req.headers.get("User-Agent")) });
|
|
476
|
+
return json({ status: "signedin", address: sig.address, linkedTo: owner }, 200, { "Set-Cookie": cookieHeader(req, sessionName, token, ADMIN_SESSION_MS / 1000) });
|
|
357
477
|
}
|
|
358
478
|
|
|
359
479
|
async function signOut(req: Request): Promise<string> {
|
|
@@ -366,7 +486,9 @@ export function createAdminSignin(options: AdminSigninOptions): AdminSignin {
|
|
|
366
486
|
const url = new URL(req.url);
|
|
367
487
|
const path = url.pathname;
|
|
368
488
|
const berryId = path.startsWith(`${base}/nano/`) ? /^[0-9a-f]{32}$/.exec(path.slice(base.length + 6))?.[0] : undefined;
|
|
369
|
-
const
|
|
489
|
+
const passkeyPaths = [`${base}/passkey`, `${base}/passkey/link`, `${base}/passkey/unlink`, `${base}/passkeys`];
|
|
490
|
+
const mine =
|
|
491
|
+
berryId !== undefined || [`${base}/start`, `${base}/check`, `${base}/signout`, `${base}/nautilus`].includes(path) || (nanchatOn && path === `${base}/nanchat`) || (passkeysOn && passkeyPaths.includes(path));
|
|
370
492
|
if (!mine) return null;
|
|
371
493
|
try {
|
|
372
494
|
if (options.domain && url.host !== options.domain) return fail(403, `This sign-in belongs to ${options.domain}.`);
|
|
@@ -393,12 +515,17 @@ export function createAdminSignin(options: AdminSigninOptions): AdminSignin {
|
|
|
393
515
|
// The browser's own calls: a POST from the game's own page.
|
|
394
516
|
if (req.method !== "POST") return fail(405, "Not allowed.", {}, { Allow: "POST" });
|
|
395
517
|
if (path === `${base}/check` && !memory(`${base}|poll|${ip}`, ADMIN_SIGNIN_LIMITS.poll.limit, ADMIN_SIGNIN_LIMITS.poll.windowMs, at)) return fail(429, "Too many requests. Wait a minute.");
|
|
518
|
+
if (passkeyPaths.includes(path) && path !== `${base}/passkey` && !memory(`${base}|passkeys|${ip}`, ADMIN_SIGNIN_LIMITS.passkeys.limit, ADMIN_SIGNIN_LIMITS.passkeys.windowMs, at)) return fail(429, "Too many requests. Wait a minute.");
|
|
396
519
|
if (!sameOrigin(req)) return fail(403, "This call must come from the game's own page.");
|
|
397
520
|
// A sign-out reads no body.
|
|
398
521
|
if (path === `${base}/signout`) return json({ ok: true }, 200, { "Set-Cookie": await signOut(req) });
|
|
399
522
|
const b = await body(req);
|
|
400
523
|
if (!b) return fail(400, "That request is not readable.");
|
|
401
524
|
if (path === `${base}/check`) return await check(req, b, at);
|
|
525
|
+
if (path === `${base}/passkey`) return await passkeySignin(req, b, at);
|
|
526
|
+
if (path === `${base}/passkey/link`) return await link(req, b, at);
|
|
527
|
+
if (path === `${base}/passkey/unlink`) return await unlink(req, b, at);
|
|
528
|
+
if (path === `${base}/passkeys`) return await list(req, at);
|
|
402
529
|
return await start(req, b, at);
|
|
403
530
|
} catch (e) {
|
|
404
531
|
report(e);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// "@nano133/nanoterra/admin-signin": the ADMIN's sign-in of a game (/admin), on ONE fixed route, "/api/admin-signin", in every game. Berry Wallet or
|
|
2
|
-
// Nautilus signs a text that names the ADMIN; NanChat is off unless the game turns it on (its text cannot name the admin);
|
|
2
|
+
// Nautilus signs a text that names the ADMIN; NanChat is off unless the game turns it on (its text cannot name the admin); the owner's linked passkeys
|
|
3
|
+
// only with `passkeys: true` (since 0.12.0; a wallet session of the owner links them, each with its own signature). The game starter's
|
|
3
4
|
// admin sign-in, moved into the package with every check and every signed text unchanged. The checks are @nano133/signin's (an optional peer: install it
|
|
4
5
|
// with this package).
|
|
5
6
|
//
|
|
@@ -17,13 +18,17 @@ export { countryOf, shortBrowser } from "./browser.js";
|
|
|
17
18
|
export {
|
|
18
19
|
ADMIN_DELIVER_MS,
|
|
19
20
|
ADMIN_LOGIN_KEEP_MS,
|
|
21
|
+
ADMIN_ONCE_MS,
|
|
20
22
|
ADMIN_PAGE,
|
|
23
|
+
ADMIN_PASSKEY_LIMIT,
|
|
21
24
|
ADMIN_REQUEST_MS,
|
|
22
25
|
ADMIN_SIGNIN_BASE,
|
|
23
26
|
ADMIN_SIGNIN_LIMITS,
|
|
24
27
|
ADMIN_STARTER_MAX_AGE_S,
|
|
25
28
|
ADMIN_WAYS,
|
|
26
29
|
NANCHAT_REFUSAL,
|
|
30
|
+
adminLinkDomain,
|
|
31
|
+
adminPasskeyDomain,
|
|
27
32
|
} from "./types.js";
|
|
28
|
-
export type { AdminSessionRecord, AdminSigninSession, AdminSigninStore, AdminWay, RequestStoreLike, SigninRequest } from "./types.js";
|
|
33
|
+
export type { AdminLinkResult, AdminPasskey, AdminSessionRecord, AdminSigninSession, AdminSigninStore, AdminWay, RequestStoreLike, SigninRequest } from "./types.js";
|
|
29
34
|
export { ADMIN_SESSION_MS } from "../admin/guard.js";
|