@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +110 -12
  3. package/ai/SPEC.md +11 -4
  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 +3 -1
  14. package/dist/admin-signin/adapters/d1.js +58 -3
  15. package/dist/admin-signin/adapters/firestore.d.ts +5 -2
  16. package/dist/admin-signin/adapters/firestore.js +74 -5
  17. package/dist/admin-signin/conformance.d.ts +8 -1
  18. package/dist/admin-signin/conformance.js +88 -3
  19. package/dist/admin-signin/handler.d.ts +7 -0
  20. package/dist/admin-signin/handler.js +134 -6
  21. package/dist/admin-signin/index.d.ts +2 -2
  22. package/dist/admin-signin/index.js +3 -2
  23. package/dist/admin-signin/memory.d.ts +6 -2
  24. package/dist/admin-signin/memory.js +47 -1
  25. package/dist/admin-signin/panel/index.d.ts +3 -0
  26. package/dist/admin-signin/panel/index.js +4 -2
  27. package/dist/admin-signin/panel/passkeys.d.ts +43 -0
  28. package/dist/admin-signin/panel/passkeys.js +202 -0
  29. package/dist/admin-signin/panel/view.d.ts +12 -0
  30. package/dist/admin-signin/panel/view.js +48 -1
  31. package/dist/admin-signin/types.d.ts +57 -7
  32. package/dist/admin-signin/types.js +20 -3
  33. package/dist/input/index.d.ts +2 -1
  34. package/dist/input/index.js +3 -0
  35. package/dist/input/zoom.d.ts +81 -0
  36. package/dist/input/zoom.js +140 -0
  37. package/package.json +1 -1
  38. package/src/admin/guard.ts +19 -5
  39. package/src/admin/panel/card.ts +5 -0
  40. package/src/admin/panel/icons.ts +1 -0
  41. package/src/admin/panel/index.ts +1 -1
  42. package/src/admin/panel/mount.ts +11 -1
  43. package/src/admin/panel/style.css +44 -0
  44. package/src/admin-signin/adapters/d1.sql +20 -3
  45. package/src/admin-signin/adapters/d1.ts +58 -5
  46. package/src/admin-signin/adapters/firestore.ts +73 -6
  47. package/src/admin-signin/conformance.ts +99 -3
  48. package/src/admin-signin/handler.ts +131 -4
  49. package/src/admin-signin/index.ts +7 -2
  50. package/src/admin-signin/memory.ts +43 -2
  51. package/src/admin-signin/panel/index.ts +6 -2
  52. package/src/admin-signin/panel/passkeys.ts +245 -0
  53. package/src/admin-signin/panel/view.ts +63 -1
  54. package/src/admin-signin/types.ts +62 -11
  55. package/src/input/index.ts +5 -0
  56. package/src/input/zoom.ts +160 -0
  57. 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
- // Times are ms numbers; `deleteAt` is a Date (a Timestamp in Firestore): set a TTL policy on `deleteAt` for each collection. `{key}` is the request's id;
13
- // `{idHash}` the SHA-256 of "admin:" and the session's token. Every change that reads first is a transaction. Indexes: Firestore's own single-field
14
- // indexes are enough (`id`, `scope`, `expiresAt`, `at`).
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 deleteAll([...old, ...ended, ...logins]);
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 declare function adminSigninStoreConformance(make: () => Promise<AdminSigninHarness>): AdminSigninConformanceCase[];
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. (The whole chain, with the player's sign-in beside it, is `adminSigninConformance` in
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
- // Only the two 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).
288
- return s ? { address: s.address, createdAt: s.createdAt, via: s.via === "wallet" || s.via === "nanoterra" ? s.via : "unknown" } : null;
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 mine = berryId !== undefined || [`${base}/start`, `${base}/check`, `${base}/signout`, `${base}/nautilus`].includes(path) || (nanchatOn && path === `${base}/nanchat`);
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); no passkey. The game starter's
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. Add "@nano133/nanoterra/admin-signin/style.css" to the page (the player
3
- // sheet's stylesheet: the two sign-ins look the same). No package, no Three.js; the server part is "@nano133/nanoterra/admin-signin".
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";