@nano133/nanoterra 0.11.0 → 0.13.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 +37 -0
- package/README.md +110 -12
- 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/dist/input/index.d.ts +2 -1
- package/dist/input/index.js +3 -0
- package/dist/input/zoom.d.ts +81 -0
- package/dist/input/zoom.js +140 -0
- 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/input/index.ts +5 -0
- package/src/input/zoom.ts +160 -0
- package/src/player-signin/panel/style.css +3 -2
|
@@ -9,13 +9,16 @@
|
|
|
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
|
-
import { ADMIN_LOGIN_KEEP_MS, ADMIN_WAYS } from "../types.js";
|
|
21
|
+
import { ADMIN_LOGIN_KEEP_MS, ADMIN_PASSKEY_LIMIT, ADMIN_WAYS } from "../types.js";
|
|
19
22
|
const DAY = 86_400_000;
|
|
20
23
|
/** At most this many documents go in one batch (Firestore's limit is 500). */
|
|
21
24
|
const BATCH = 400;
|
|
@@ -40,10 +43,21 @@ function requestOf(d, key, way) {
|
|
|
40
43
|
...(account !== undefined ? { account } : {}),
|
|
41
44
|
};
|
|
42
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
|
+
}
|
|
43
55
|
export function createFirestoreAdminSignin(db, collections = {}) {
|
|
44
56
|
const requestsCol = () => db.collection(collections.requests ?? "signin_requests");
|
|
45
57
|
const sessionsCol = () => db.collection(collections.sessions ?? "sessions");
|
|
46
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");
|
|
47
61
|
const sessionData = (s) => ({ address: s.address, createdAt: s.createdAt, expiresAt: s.expiresAt, via: s.via, scope: "admin", deleteAt: new Date(s.expiresAt) });
|
|
48
62
|
const stores = new Map();
|
|
49
63
|
for (const way of ADMIN_WAYS) {
|
|
@@ -173,7 +187,62 @@ export function createFirestoreAdminSignin(db, collections = {}) {
|
|
|
173
187
|
const old = (await requestsCol().where("expiresAt", "<", now - 2 * 3_600_000).limit(BATCH).get()).docs.filter((d) => d.data().scope === "admin");
|
|
174
188
|
const ended = (await sessionsCol().where("expiresAt", "<", now).limit(BATCH).get()).docs.filter((d) => d.data().scope === "admin");
|
|
175
189
|
const logins = (await loginsCol().where("at", "<", now - ADMIN_LOGIN_KEEP_MS).limit(BATCH).get()).docs;
|
|
176
|
-
await
|
|
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
|
+
});
|
|
177
246
|
},
|
|
178
247
|
};
|
|
179
248
|
}
|
|
@@ -8,4 +8,11 @@ export type AdminSigninConformanceCase = {
|
|
|
8
8
|
name: string;
|
|
9
9
|
run: () => Promise<void>;
|
|
10
10
|
};
|
|
11
|
-
export
|
|
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[];
|
|
@@ -6,9 +6,10 @@
|
|
|
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
|
-
import { ADMIN_LOGIN_KEEP_MS } from "./types.js";
|
|
12
|
+
import { ADMIN_LOGIN_KEEP_MS, ADMIN_PASSKEY_LIMIT } from "./types.js";
|
|
12
13
|
function check(ok, message) {
|
|
13
14
|
if (!ok)
|
|
14
15
|
throw new Error(message);
|
|
@@ -25,9 +26,22 @@ function request(at, extra = {}) {
|
|
|
25
26
|
return { key: id, r: { id, starter: hex(serial + 7), status: "waiting", expiresAt: at + 2 * 60_000, nonce: hex(serial + 99), issuedAt: at, ...extra } };
|
|
26
27
|
}
|
|
27
28
|
const session = (at, n, via = "wallet") => ({ idHash: hex(1_000_000 + n + serial++), address: OWNER, createdAt: at, expiresAt: at + 86_400_000, via });
|
|
28
|
-
export function adminSigninStoreConformance(make) {
|
|
29
|
+
export function adminSigninStoreConformance(make, options = {}) {
|
|
29
30
|
const cases = [];
|
|
30
31
|
const add = (name, run) => cases.push({ name, run: async () => run(await make(), Date.now()) });
|
|
32
|
+
/** A passkey case: it runs only on a store with the passkey methods (required with `passkeys: true`). */
|
|
33
|
+
const addPasskey = (name, run) => {
|
|
34
|
+
if (options.passkeys === false)
|
|
35
|
+
return;
|
|
36
|
+
add(`passkeys: ${name}`, async (h, now) => {
|
|
37
|
+
const has = ["createSession", "linkPasskey", "unlinkPasskey", "passkeys", "linked", "useOnce"].every((m) => typeof h.store[m] === "function");
|
|
38
|
+
if (!has) {
|
|
39
|
+
check(options.passkeys !== true, "the store has no passkey methods (createSession, linkPasskey, unlinkPasskey, passkeys, linked, useOnce)");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
await run(h.store, now, h);
|
|
43
|
+
});
|
|
44
|
+
};
|
|
31
45
|
add("a request reads back as it was made, by its key and by its browser id, and only for its own wallet app", async ({ store }, now) => {
|
|
32
46
|
const { key, r } = request(now, { label: "ADMIN of game.test sign-in ABCD-EFGH from US" });
|
|
33
47
|
await store.requests("nautilus").create(key, r);
|
|
@@ -186,5 +200,76 @@ export function adminSigninStoreConformance(make) {
|
|
|
186
200
|
check((await store.requests("berry").get(fresh.key)) !== null, "the fresh request stays");
|
|
187
201
|
check((await store.recentLogins(10)).length === 0, "the sign-in row past 30 days went");
|
|
188
202
|
});
|
|
203
|
+
addPasskey("a link is written once and read back exactly, oldest first, for its owner only", async (store, now) => {
|
|
204
|
+
const a = who("pka");
|
|
205
|
+
const b = who("pkb");
|
|
206
|
+
check((await store.passkeys(OWNER)).length === 0, "a new store lists no passkey");
|
|
207
|
+
check(!(await store.linked(OWNER, a)), "nothing is linked yet");
|
|
208
|
+
check((await store.linkPasskey(OWNER, a, now - 1000)) === "linked", "the first link");
|
|
209
|
+
check((await store.linkPasskey(OWNER, b, now)) === "linked", "a second passkey");
|
|
210
|
+
check((await store.linkPasskey(OWNER, a, now + 5)) === "exists", "the same passkey again is 'exists'");
|
|
211
|
+
const list = await store.passkeys(OWNER);
|
|
212
|
+
check(list.length === 2 && list[0].address === a && list[0].createdAt === now - 1000 && list[1].address === b, `the list: ${JSON.stringify(list)}`);
|
|
213
|
+
check(list.every((p) => Object.keys(p).sort().join() === "address,createdAt"), `a passkey carries only address and createdAt: ${JSON.stringify(list[0])}`);
|
|
214
|
+
check(await store.linked(OWNER, a), "linked");
|
|
215
|
+
check(!(await store.linked(who("oth"), a)), "another owner has no link to it");
|
|
216
|
+
check(!(await store.linked(OWNER.toUpperCase(), a)) && !(await store.linked(OWNER, a.toUpperCase())) && !(await store.linked(` ${OWNER}`, a)), "only the exact texts are linked");
|
|
217
|
+
check((await store.passkeys(who("oth"))).length === 0, "another owner lists none");
|
|
218
|
+
});
|
|
219
|
+
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) => {
|
|
220
|
+
const many = Array.from({ length: ADMIN_PASSKEY_LIMIT + 4 }, (_, i) => who(`m${i}`));
|
|
221
|
+
const got = await Promise.all(many.map((a, i) => store.linkPasskey(OWNER, a, now + i)));
|
|
222
|
+
check(got.filter((r) => r === "linked").length === ADMIN_PASSKEY_LIMIT && got.filter((r) => r === "full").length === 4, `results: ${JSON.stringify(got)}`);
|
|
223
|
+
check((await store.passkeys(OWNER)).length === ADMIN_PASSKEY_LIMIT, "the list holds the limit");
|
|
224
|
+
check((await store.linkPasskey(OWNER, who("late"), now)) === "full", "full");
|
|
225
|
+
const linked = (await store.passkeys(OWNER))[0].address;
|
|
226
|
+
check((await store.linkPasskey(OWNER, linked, now)) === "exists", "a passkey that is there is 'exists', not 'full'");
|
|
227
|
+
check((await store.linkPasskey(who("oth"), who("late"), now)) === "linked", "another owner is not full");
|
|
228
|
+
check(await store.unlinkPasskey(OWNER, linked), "a removal");
|
|
229
|
+
check((await store.linkPasskey(OWNER, who("late"), now)) === "linked", "a removal makes room for one");
|
|
230
|
+
});
|
|
231
|
+
addPasskey("a removal ends the link and every admin session of that passkey, and nothing else", async (store, now) => {
|
|
232
|
+
const a = who("rma");
|
|
233
|
+
const b = who("rmb");
|
|
234
|
+
await store.linkPasskey(OWNER, a, now);
|
|
235
|
+
await store.linkPasskey(OWNER, b, now);
|
|
236
|
+
const sa = { ...session(now, 60, "passkey"), address: a };
|
|
237
|
+
const sb = { ...session(now, 61, "passkey"), address: b };
|
|
238
|
+
const owner = session(now, 62, "wallet");
|
|
239
|
+
for (const s of [sa, sb, owner])
|
|
240
|
+
await store.createSession(s);
|
|
241
|
+
const read = await store.session(sa.idHash, now);
|
|
242
|
+
check(read?.address === a && read.via === "passkey" && read.createdAt === now && read.expiresAt === sa.expiresAt, `a passkey session reads back: ${JSON.stringify(read)}`);
|
|
243
|
+
check(await store.unlinkPasskey(OWNER, a), "the removal says it removed one");
|
|
244
|
+
check(!(await store.unlinkPasskey(OWNER, a)), "a second removal says false");
|
|
245
|
+
check(!(await store.linked(OWNER, a)) && (await store.linked(OWNER, b)), "only that link went");
|
|
246
|
+
check((await store.session(sa.idHash, now)) === null, "the passkey's session ended with its link");
|
|
247
|
+
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");
|
|
248
|
+
let threw = false;
|
|
249
|
+
try {
|
|
250
|
+
await store.createSession(sb);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
threw = true;
|
|
254
|
+
}
|
|
255
|
+
check(threw, "a second session with the same hash is refused");
|
|
256
|
+
check((await store.signOutEverywhere()) === 2, "Sign out everywhere ends the passkey sessions too");
|
|
257
|
+
check((await store.session(sb.idHash, now)) === null, "and the passkey's session is gone");
|
|
258
|
+
check(await store.linked(OWNER, b), "Sign out everywhere keeps the links (a removal ends one)");
|
|
259
|
+
});
|
|
260
|
+
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) => {
|
|
261
|
+
const k = hex(31337);
|
|
262
|
+
check(await store.useOnce(k, 60_000), "the first use");
|
|
263
|
+
check(!(await store.useOnce(k, 60_000)), "the second use is refused");
|
|
264
|
+
const k2 = hex(31338);
|
|
265
|
+
const both = await Promise.all([store.useOnce(k2, 60_000), store.useOnce(k2, 60_000)]);
|
|
266
|
+
check(both.filter(Boolean).length === 1, `two at once: ${JSON.stringify(both)}`);
|
|
267
|
+
const k3 = hex(31339);
|
|
268
|
+
check(await store.useOnce(k3, 1), "a short one");
|
|
269
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
270
|
+
check(await store.useOnce(k3, 60_000), "past its time it may be used again");
|
|
271
|
+
await store.clean(Date.now() + 120_000);
|
|
272
|
+
check(await store.useOnce(k, 60_000), "clean dropped the used row past its time");
|
|
273
|
+
});
|
|
189
274
|
return cases;
|
|
190
275
|
}
|
|
@@ -22,6 +22,13 @@ export type AdminSigninOptions = {
|
|
|
22
22
|
* game sign-in. Turn it on only when the owner accepts that; give the panel the same `nanchat: true`.
|
|
23
23
|
*/
|
|
24
24
|
nanchat?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* 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
|
|
27
|
+
* 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
|
|
28
|
+
* 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
|
|
29
|
+
* panel the same `passkeys: true` and the game's frozen passkey `scheme`.
|
|
30
|
+
*/
|
|
31
|
+
passkeys?: boolean;
|
|
25
32
|
/** The cookies' names. Default { session: "admin", starter: "starter-admin" } (the starter's). On https they get the __Host- prefix. */
|
|
26
33
|
cookies?: {
|
|
27
34
|
session?: string;
|
|
@@ -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
|
|
@@ -23,11 +36,11 @@
|
|
|
23
36
|
// and a player's session keeps the plain hash: a player's token put in the admin cookie opens nothing, and a starter value copied from the player's cookie
|
|
24
37
|
// finishes no admin request. Only the game's own address (`owner`) makes an admin login. DO NOT CHANGE the checks in this file: they are what makes the
|
|
25
38
|
// admin page the creator's.
|
|
26
|
-
import { answerNanchat, answerNanoSignin, answerNautilus, checkNanchat, checkNanoSignin, checkNautilus, nanchatNonce, nanchatReply, NANO_SIGNIN_ACCEPTED, nanoSigninRefusal, nautilusReply, readNanoSignin, startNanchat, startNanoSignin, startNautilus, validAddress, WalletRefused, } from "@nano133/signin";
|
|
27
|
-
import { ADMIN_SESSION_MS } from "../admin/guard.js";
|
|
39
|
+
import { answerNanchat, answerNanoSignin, answerNautilus, checkNanchat, checkSignatureSignin, checkNanoSignin, checkNautilus, nanchatNonce, nanchatReply, NANO_SIGNIN_ACCEPTED, nanoSigninRefusal, nautilusReply, readNanoSignin, SigninError, startNanchat, startNanoSignin, startNautilus, validAddress, WalletRefused, } from "@nano133/signin";
|
|
40
|
+
import { ADMIN_SESSION_MS, adminAccess } from "../admin/guard.js";
|
|
28
41
|
import { fail, json, memoryLimiter, readLimited, sameOrigin } from "../admin/http.js";
|
|
29
42
|
import { countryOf, shortBrowser } from "./browser.js";
|
|
30
|
-
import { ADMIN_DELIVER_MS, ADMIN_PAGE, ADMIN_REQUEST_MS, ADMIN_SIGNIN_BASE, ADMIN_SIGNIN_LIMITS, ADMIN_STARTER_MAX_AGE_S, ADMIN_WAYS, NANCHAT_REFUSAL, TOKEN_PATTERN, } from "./types.js";
|
|
43
|
+
import { ADMIN_DELIVER_MS, ADMIN_ONCE_MS, ADMIN_PAGE, ADMIN_PASSKEY_LIMIT, ADMIN_REQUEST_MS, ADMIN_SIGNIN_BASE, ADMIN_SIGNIN_LIMITS, ADMIN_STARTER_MAX_AGE_S, ADMIN_WAYS, NANCHAT_REFUSAL, TOKEN_PATTERN, adminLinkDomain, adminPasskeyDomain, } from "./types.js";
|
|
31
44
|
/** The address that belongs to nobody (its key is all zeros): Nautilus names it while the game has no address of its own. */
|
|
32
45
|
export const EXAMPLE_ADDRESS = "nano_1111111111111111111111111111111111111111111111111111hifc8npp";
|
|
33
46
|
const hex = (b) => Array.from(b instanceof Uint8Array ? b : new Uint8Array(b), (x) => x.toString(16).padStart(2, "0")).join("");
|
|
@@ -63,6 +76,16 @@ function cookieHeader(req, name, value, maxAgeSec) {
|
|
|
63
76
|
}
|
|
64
77
|
/** A cookie's value: on https only the `__Host-` name (a sibling subdomain can set the plain one; it cannot set a `__Host-` one). */
|
|
65
78
|
const readCookie = (req, name) => cookieValue(req, isHttps(req) ? `__Host-${name}` : name);
|
|
79
|
+
/** What the owner reads for a refused passkey signature. */
|
|
80
|
+
function passkeyWords(e) {
|
|
81
|
+
if (e.status === 409)
|
|
82
|
+
return "That passkey signature was used already. Try again.";
|
|
83
|
+
if (e.status === 401)
|
|
84
|
+
return "The passkey's signature does not match. Try again.";
|
|
85
|
+
if (/too old/.test(e.message))
|
|
86
|
+
return "That signature is too old. Check the clock of this device, then try again.";
|
|
87
|
+
return "That passkey signature could not be read. Try again.";
|
|
88
|
+
}
|
|
66
89
|
/** The limiters live at module level: a Worker makes the sign-in for each request, and a limiter made inside it would forget every call. */
|
|
67
90
|
const fallbackLimit = memoryLimiter();
|
|
68
91
|
const memory = memoryLimiter();
|
|
@@ -86,6 +109,14 @@ export function createAdminSignin(options) {
|
|
|
86
109
|
const limit = options.limit ?? fallbackLimit;
|
|
87
110
|
const siteAddress = owner && validAddress(owner) ? owner : EXAMPLE_ADDRESS;
|
|
88
111
|
const nanchatOn = options.nanchat === true;
|
|
112
|
+
const passkeysOn = options.passkeys === true;
|
|
113
|
+
const pk = store;
|
|
114
|
+
if (passkeysOn) {
|
|
115
|
+
for (const m of ["createSession", "linkPasskey", "unlinkPasskey", "passkeys", "linked", "useOnce"]) {
|
|
116
|
+
if (typeof store[m] !== "function")
|
|
117
|
+
throw new Error(`passkeys: true needs a store with ${m}() (the package's stores have it)`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
89
120
|
const report = (e) => {
|
|
90
121
|
try {
|
|
91
122
|
options.onError?.(e);
|
|
@@ -284,8 +315,94 @@ export function createAdminSignin(options) {
|
|
|
284
315
|
return null;
|
|
285
316
|
// The scope is in the store's row (the adapter reads only admin rows) as well as in the hash: the two scopes stay apart in two ways.
|
|
286
317
|
const s = await store.session(await adminKey(token), at);
|
|
287
|
-
|
|
288
|
-
|
|
318
|
+
if (!s)
|
|
319
|
+
return null;
|
|
320
|
+
if (s.via === "passkey") {
|
|
321
|
+
// 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
|
|
322
|
+
// passkeys turned off: no session at all (the page asks for a sign-in). `linkedTo` is set here only, and only after this check.
|
|
323
|
+
if (!passkeysOn || typeof owner !== "string" || !validAddress(owner))
|
|
324
|
+
return null;
|
|
325
|
+
return (await pk.linked(owner, s.address)) ? { address: s.address, createdAt: s.createdAt, via: "passkey", linkedTo: owner } : null;
|
|
326
|
+
}
|
|
327
|
+
// 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).
|
|
328
|
+
return { address: s.address, createdAt: s.createdAt, via: s.via === "wallet" || s.via === "nanoterra" ? s.via : "unknown" };
|
|
329
|
+
}
|
|
330
|
+
// ---- the owner's passkeys ----
|
|
331
|
+
/** The host every passkey text names: `domain`, or this request's host. */
|
|
332
|
+
const hostOf = (req) => options.domain ?? new URL(req.url).host;
|
|
333
|
+
/** The session the guard accepts now, or the refusal to send. `wallet`: only a wallet session of the owner (a link and a removal). */
|
|
334
|
+
async function ownerSession(req, at, wallet) {
|
|
335
|
+
const session = await sessionOf(req, at);
|
|
336
|
+
const access = adminAccess({ owner, session, now: at });
|
|
337
|
+
if (!access.ok)
|
|
338
|
+
return { ok: false, res: fail(access.status, access.error, access.reason ? { reason: access.reason } : {}) };
|
|
339
|
+
if (wallet && session.via !== "wallet")
|
|
340
|
+
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" }) };
|
|
341
|
+
return { ok: true, session: session };
|
|
342
|
+
}
|
|
343
|
+
/** A passkey's signature of `domain`'s text, checked once: its address, or the refusal to send. */
|
|
344
|
+
async function passkeySignature(b, domain, label, at) {
|
|
345
|
+
try {
|
|
346
|
+
const address = await checkSignatureSignin({ domain, useOnce: (k, ttl) => pk.useOnce(k, Math.max(ttl, ADMIN_ONCE_MS)), now: () => at, onceLabel: label }, { address: b.address, at: Number(b.at), signature: b.signature });
|
|
347
|
+
return { ok: true, address };
|
|
348
|
+
}
|
|
349
|
+
catch (e) {
|
|
350
|
+
if (e instanceof SigninError)
|
|
351
|
+
return { ok: false, res: fail(e.status, passkeyWords(e)) };
|
|
352
|
+
throw e;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async function passkeyList(session) {
|
|
356
|
+
return { passkeys: await pk.passkeys(owner), limit: ADMIN_PASSKEY_LIMIT, via: session.via, canChange: session.via === "wallet", you: session.address };
|
|
357
|
+
}
|
|
358
|
+
async function link(req, b, at) {
|
|
359
|
+
const o = await ownerSession(req, at, true);
|
|
360
|
+
if (!o.ok)
|
|
361
|
+
return o.res;
|
|
362
|
+
if (!(await limit(`signin|${await visitorOf(req)}`, ADMIN_SIGNIN_LIMITS.signin.limit, ADMIN_SIGNIN_LIMITS.signin.windowMs, at)))
|
|
363
|
+
return fail(429, "Too many tries. Wait a few minutes.");
|
|
364
|
+
const sig = await passkeySignature(b, adminLinkDomain(hostOf(req)), "nanoterra admin passkey link v1", at);
|
|
365
|
+
if (!sig.ok)
|
|
366
|
+
return sig.res;
|
|
367
|
+
const r = await pk.linkPasskey(owner, sig.address, at);
|
|
368
|
+
if (r === "full")
|
|
369
|
+
return fail(409, `You have ${ADMIN_PASSKEY_LIMIT} passkeys. Remove one first.`, { reason: "full" });
|
|
370
|
+
if (r === "exists")
|
|
371
|
+
return fail(409, "This passkey is linked already.", { reason: "exists" });
|
|
372
|
+
return json({ ok: true, address: sig.address, ...(await passkeyList(o.session)) });
|
|
373
|
+
}
|
|
374
|
+
async function unlink(req, b, at) {
|
|
375
|
+
const o = await ownerSession(req, at, true);
|
|
376
|
+
if (!o.ok)
|
|
377
|
+
return o.res;
|
|
378
|
+
if (typeof b.address !== "string" || !validAddress(b.address))
|
|
379
|
+
return fail(400, "That is not a passkey's address.");
|
|
380
|
+
const removed = await pk.unlinkPasskey(owner, b.address);
|
|
381
|
+
return json({ ok: true, removed, ...(await passkeyList(o.session)) });
|
|
382
|
+
}
|
|
383
|
+
async function list(req, at) {
|
|
384
|
+
const o = await ownerSession(req, at, false);
|
|
385
|
+
if (!o.ok)
|
|
386
|
+
return o.res;
|
|
387
|
+
return json(await passkeyList(o.session));
|
|
388
|
+
}
|
|
389
|
+
async function passkeySignin(req, b, at) {
|
|
390
|
+
if (!(await limit(`signin|${await visitorOf(req)}`, ADMIN_SIGNIN_LIMITS.signin.limit, ADMIN_SIGNIN_LIMITS.signin.windowMs, at)))
|
|
391
|
+
return fail(429, "Too many sign-ins. Wait a few minutes.");
|
|
392
|
+
if (!(await person(b.turnstile, req)))
|
|
393
|
+
return fail(403, "The person check failed. Try again.");
|
|
394
|
+
if (typeof owner !== "string" || !validAddress(owner))
|
|
395
|
+
return fail(403, "Set the game's own Nano address first. Only that address can open this page.", { reason: "no_address" });
|
|
396
|
+
const sig = await passkeySignature(b, adminPasskeyDomain(hostOf(req)), "nanoterra admin passkey signin v1", at);
|
|
397
|
+
if (!sig.ok)
|
|
398
|
+
return sig.res;
|
|
399
|
+
// The signature is checked first, so a stranger learns nothing of which passkeys are linked.
|
|
400
|
+
if (!(await pk.linked(owner, sig.address)))
|
|
401
|
+
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" });
|
|
402
|
+
const token = randomHex(32);
|
|
403
|
+
await pk.createSession({ idHash: await adminKey(token), address: sig.address, createdAt: at, expiresAt: at + ADMIN_SESSION_MS, via: "passkey" });
|
|
404
|
+
await store.recordLogin({ at, country: countryOf(req), browser: shortBrowser(req.headers.get("User-Agent")) });
|
|
405
|
+
return json({ status: "signedin", address: sig.address, linkedTo: owner }, 200, { "Set-Cookie": cookieHeader(req, sessionName, token, ADMIN_SESSION_MS / 1000) });
|
|
289
406
|
}
|
|
290
407
|
async function signOut(req) {
|
|
291
408
|
const token = readCookie(req, sessionName);
|
|
@@ -297,7 +414,8 @@ export function createAdminSignin(options) {
|
|
|
297
414
|
const url = new URL(req.url);
|
|
298
415
|
const path = url.pathname;
|
|
299
416
|
const berryId = path.startsWith(`${base}/nano/`) ? /^[0-9a-f]{32}$/.exec(path.slice(base.length + 6))?.[0] : undefined;
|
|
300
|
-
const
|
|
417
|
+
const passkeyPaths = [`${base}/passkey`, `${base}/passkey/link`, `${base}/passkey/unlink`, `${base}/passkeys`];
|
|
418
|
+
const mine = berryId !== undefined || [`${base}/start`, `${base}/check`, `${base}/signout`, `${base}/nautilus`].includes(path) || (nanchatOn && path === `${base}/nanchat`) || (passkeysOn && passkeyPaths.includes(path));
|
|
301
419
|
if (!mine)
|
|
302
420
|
return null;
|
|
303
421
|
try {
|
|
@@ -331,6 +449,8 @@ export function createAdminSignin(options) {
|
|
|
331
449
|
return fail(405, "Not allowed.", {}, { Allow: "POST" });
|
|
332
450
|
if (path === `${base}/check` && !memory(`${base}|poll|${ip}`, ADMIN_SIGNIN_LIMITS.poll.limit, ADMIN_SIGNIN_LIMITS.poll.windowMs, at))
|
|
333
451
|
return fail(429, "Too many requests. Wait a minute.");
|
|
452
|
+
if (passkeyPaths.includes(path) && path !== `${base}/passkey` && !memory(`${base}|passkeys|${ip}`, ADMIN_SIGNIN_LIMITS.passkeys.limit, ADMIN_SIGNIN_LIMITS.passkeys.windowMs, at))
|
|
453
|
+
return fail(429, "Too many requests. Wait a minute.");
|
|
334
454
|
if (!sameOrigin(req))
|
|
335
455
|
return fail(403, "This call must come from the game's own page.");
|
|
336
456
|
// A sign-out reads no body.
|
|
@@ -341,6 +461,14 @@ export function createAdminSignin(options) {
|
|
|
341
461
|
return fail(400, "That request is not readable.");
|
|
342
462
|
if (path === `${base}/check`)
|
|
343
463
|
return await check(req, b, at);
|
|
464
|
+
if (path === `${base}/passkey`)
|
|
465
|
+
return await passkeySignin(req, b, at);
|
|
466
|
+
if (path === `${base}/passkey/link`)
|
|
467
|
+
return await link(req, b, at);
|
|
468
|
+
if (path === `${base}/passkey/unlink`)
|
|
469
|
+
return await unlink(req, b, at);
|
|
470
|
+
if (path === `${base}/passkeys`)
|
|
471
|
+
return await list(req, at);
|
|
344
472
|
return await start(req, b, at);
|
|
345
473
|
}
|
|
346
474
|
catch (e) {
|
|
@@ -3,6 +3,6 @@ export type { AdminSignin, AdminSigninOptions } from "./handler.js";
|
|
|
3
3
|
export { createMemoryAdminSignin } from "./memory.js";
|
|
4
4
|
export type { MemoryAdminSignin } from "./memory.js";
|
|
5
5
|
export { countryOf, shortBrowser } from "./browser.js";
|
|
6
|
-
export { ADMIN_DELIVER_MS, ADMIN_LOGIN_KEEP_MS, ADMIN_PAGE, ADMIN_REQUEST_MS, ADMIN_SIGNIN_BASE, ADMIN_SIGNIN_LIMITS, ADMIN_STARTER_MAX_AGE_S, ADMIN_WAYS, NANCHAT_REFUSAL, } from "./types.js";
|
|
7
|
-
export type { AdminSessionRecord, AdminSigninSession, AdminSigninStore, AdminWay, RequestStoreLike, SigninRequest } from "./types.js";
|
|
6
|
+
export { ADMIN_DELIVER_MS, ADMIN_LOGIN_KEEP_MS, ADMIN_ONCE_MS, ADMIN_PAGE, ADMIN_PASSKEY_LIMIT, ADMIN_REQUEST_MS, ADMIN_SIGNIN_BASE, ADMIN_SIGNIN_LIMITS, ADMIN_STARTER_MAX_AGE_S, ADMIN_WAYS, NANCHAT_REFUSAL, adminLinkDomain, adminPasskeyDomain, } from "./types.js";
|
|
7
|
+
export type { AdminLinkResult, AdminPasskey, AdminSessionRecord, AdminSigninSession, AdminSigninStore, AdminWay, RequestStoreLike, SigninRequest } from "./types.js";
|
|
8
8
|
export { ADMIN_SESSION_MS } from "../admin/guard.js";
|
|
@@ -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
|
//
|
|
@@ -11,5 +12,5 @@
|
|
|
11
12
|
export { EXAMPLE_ADDRESS, adminKey, createAdminSignin } from "./handler.js";
|
|
12
13
|
export { createMemoryAdminSignin } from "./memory.js";
|
|
13
14
|
export { countryOf, shortBrowser } from "./browser.js";
|
|
14
|
-
export { ADMIN_DELIVER_MS, ADMIN_LOGIN_KEEP_MS, ADMIN_PAGE, ADMIN_REQUEST_MS, ADMIN_SIGNIN_BASE, ADMIN_SIGNIN_LIMITS, ADMIN_STARTER_MAX_AGE_S, ADMIN_WAYS, NANCHAT_REFUSAL, } from "./types.js";
|
|
15
|
+
export { ADMIN_DELIVER_MS, ADMIN_LOGIN_KEEP_MS, ADMIN_ONCE_MS, ADMIN_PAGE, ADMIN_PASSKEY_LIMIT, ADMIN_REQUEST_MS, ADMIN_SIGNIN_BASE, ADMIN_SIGNIN_LIMITS, ADMIN_STARTER_MAX_AGE_S, ADMIN_WAYS, NANCHAT_REFUSAL, adminLinkDomain, adminPasskeyDomain, } from "./types.js";
|
|
15
16
|
export { ADMIN_SESSION_MS } from "../admin/guard.js";
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { AdminLogin } from "../admin/store.js";
|
|
2
|
-
import { type AdminSessionRecord, type AdminSigninStore, type AdminWay, type SigninRequest } from "./types.js";
|
|
2
|
+
import { type AdminPasskey, type AdminSessionRecord, type AdminSigninStore, type AdminWay, type SigninRequest } from "./types.js";
|
|
3
3
|
type RequestRow = SigninRequest & {
|
|
4
4
|
way: AdminWay;
|
|
5
5
|
delivered: boolean;
|
|
6
6
|
};
|
|
7
|
-
export type MemoryAdminSignin = AdminSigninStore & {
|
|
7
|
+
export type MemoryAdminSignin = Required<AdminSigninStore> & {
|
|
8
8
|
readonly requestRows: Map<string, RequestRow>;
|
|
9
9
|
readonly sessionRows: Map<string, AdminSessionRecord>;
|
|
10
10
|
readonly loginRows: AdminLogin[];
|
|
11
|
+
/** The owner's passkeys: owner → the links, oldest first. */
|
|
12
|
+
readonly passkeyRows: Map<string, AdminPasskey[]>;
|
|
13
|
+
/** The used passkey signatures: key → until (ms). */
|
|
14
|
+
readonly onceRows: Map<string, number>;
|
|
11
15
|
};
|
|
12
16
|
export declare function createMemoryAdminSignin(): MemoryAdminSignin;
|
|
13
17
|
export {};
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// The admin sign-in's store in this process's memory: for tests and demos. A game uses a database (./adapters/d1.ts, ./adapters/firestore.ts, or its
|
|
2
2
|
// own store that passes adminSigninStoreConformance).
|
|
3
|
-
import { ADMIN_LOGIN_KEEP_MS, ADMIN_WAYS } from "./types.js";
|
|
3
|
+
import { ADMIN_LOGIN_KEEP_MS, ADMIN_PASSKEY_LIMIT, ADMIN_WAYS } from "./types.js";
|
|
4
4
|
const copy = (v) => structuredClone(v);
|
|
5
5
|
export function createMemoryAdminSignin() {
|
|
6
6
|
const requestRows = new Map();
|
|
7
7
|
const sessionRows = new Map();
|
|
8
8
|
const loginRows = [];
|
|
9
|
+
const passkeyRows = new Map();
|
|
10
|
+
const onceRows = new Map();
|
|
9
11
|
const strip = ({ way: _way, delivered: _delivered, ...r }) => copy(r);
|
|
10
12
|
const stores = new Map();
|
|
11
13
|
for (const way of ADMIN_WAYS) {
|
|
@@ -39,6 +41,8 @@ export function createMemoryAdminSignin() {
|
|
|
39
41
|
requestRows,
|
|
40
42
|
sessionRows,
|
|
41
43
|
loginRows,
|
|
44
|
+
passkeyRows,
|
|
45
|
+
onceRows,
|
|
42
46
|
requests: (way) => {
|
|
43
47
|
const s = stores.get(way);
|
|
44
48
|
if (!s)
|
|
@@ -96,6 +100,48 @@ export function createMemoryAdminSignin() {
|
|
|
96
100
|
for (let i = loginRows.length - 1; i >= 0; i--)
|
|
97
101
|
if (loginRows[i].at < now - ADMIN_LOGIN_KEEP_MS)
|
|
98
102
|
loginRows.splice(i, 1);
|
|
103
|
+
for (const [k, until] of onceRows)
|
|
104
|
+
if (until <= now)
|
|
105
|
+
onceRows.delete(k);
|
|
106
|
+
},
|
|
107
|
+
async createSession(session) {
|
|
108
|
+
if (sessionRows.has(session.idHash))
|
|
109
|
+
throw new Error("a session with this id exists");
|
|
110
|
+
sessionRows.set(session.idHash, copy(session));
|
|
111
|
+
},
|
|
112
|
+
async linkPasskey(owner, address, at) {
|
|
113
|
+
const list = passkeyRows.get(owner) ?? [];
|
|
114
|
+
if (list.some((p) => p.address === address))
|
|
115
|
+
return "exists";
|
|
116
|
+
if (list.length >= ADMIN_PASSKEY_LIMIT)
|
|
117
|
+
return "full";
|
|
118
|
+
passkeyRows.set(owner, [...list, { address, createdAt: at }]);
|
|
119
|
+
return "linked";
|
|
120
|
+
},
|
|
121
|
+
async unlinkPasskey(owner, address) {
|
|
122
|
+
const list = passkeyRows.get(owner) ?? [];
|
|
123
|
+
const had = list.some((p) => p.address === address);
|
|
124
|
+
if (had)
|
|
125
|
+
passkeyRows.set(owner, list.filter((p) => p.address !== address));
|
|
126
|
+
// The passkey's admin sessions end with its link.
|
|
127
|
+
for (const [k, s] of sessionRows)
|
|
128
|
+
if (s.via === "passkey" && s.address === address)
|
|
129
|
+
sessionRows.delete(k);
|
|
130
|
+
return had;
|
|
131
|
+
},
|
|
132
|
+
async passkeys(owner) {
|
|
133
|
+
return copy(passkeyRows.get(owner) ?? []);
|
|
134
|
+
},
|
|
135
|
+
async linked(owner, address) {
|
|
136
|
+
return (passkeyRows.get(owner) ?? []).some((p) => p.address === address);
|
|
137
|
+
},
|
|
138
|
+
async useOnce(key, ttlMs) {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const until = onceRows.get(key);
|
|
141
|
+
if (until !== undefined && until > now)
|
|
142
|
+
return false;
|
|
143
|
+
onceRows.set(key, now + ttlMs);
|
|
144
|
+
return true;
|
|
99
145
|
},
|
|
100
146
|
};
|
|
101
147
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export { ADMIN_NANCHAT_NOTE, ADMIN_SIGNIN_INTRO, adminSignIn, adminSignOut, adminSigninView } from "./view.js";
|
|
2
|
+
export { ownerPasskeys, passkeyAdminSignIn, passkeyFaultText } from "./passkeys.js";
|
|
3
|
+
export type { AdminPasskeyOptions, PasskeyList } from "./passkeys.js";
|
|
4
|
+
export type { AdminCard } from "../../admin/panel/card.js";
|
|
2
5
|
export type { AdminSigninView, AdminSigninViewOptions } from "./view.js";
|
|
3
6
|
export { ADMIN_SIGNIN_BASE } from "../types.js";
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// "@nano133/nanoterra/admin-signin/panel": the ADMIN's sign-in in the browser, for the admin page. `adminSignIn()` fits `mountAdmin`'s `signIn(into, done)`
|
|
2
|
-
// and `adminSignOut()` its `signOut`; `adminSigninView` is the content alone
|
|
3
|
-
// sheet's stylesheet: the two sign-ins look the same). No
|
|
2
|
+
// and `adminSignOut()` its `signOut`; `adminSigninView` is the content alone; `ownerPasskeys()` is the "Owner passkeys" card for `mountAdmin`'s `cards`
|
|
3
|
+
// (since 0.12.0). Add "@nano133/nanoterra/admin-signin/style.css" to the page (the player sheet's stylesheet: the two sign-ins look the same). No Three.js;
|
|
4
|
+
// the passkey code (@nano133/signin/passkey and /purse) is loaded only when a passkey is used; the server part is "@nano133/nanoterra/admin-signin".
|
|
4
5
|
export { ADMIN_NANCHAT_NOTE, ADMIN_SIGNIN_INTRO, adminSignIn, adminSignOut, adminSigninView } from "./view.js";
|
|
6
|
+
export { ownerPasskeys, passkeyAdminSignIn, passkeyFaultText } from "./passkeys.js";
|
|
5
7
|
export { ADMIN_SIGNIN_BASE } from "../types.js";
|