@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +84 -11
  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/package.json +1 -1
  34. package/src/admin/guard.ts +19 -5
  35. package/src/admin/panel/card.ts +5 -0
  36. package/src/admin/panel/icons.ts +1 -0
  37. package/src/admin/panel/index.ts +1 -1
  38. package/src/admin/panel/mount.ts +11 -1
  39. package/src/admin/panel/style.css +44 -0
  40. package/src/admin-signin/adapters/d1.sql +20 -3
  41. package/src/admin-signin/adapters/d1.ts +58 -5
  42. package/src/admin-signin/adapters/firestore.ts +73 -6
  43. package/src/admin-signin/conformance.ts +99 -3
  44. package/src/admin-signin/handler.ts +131 -4
  45. package/src/admin-signin/index.ts +7 -2
  46. package/src/admin-signin/memory.ts +43 -2
  47. package/src/admin-signin/panel/index.ts +6 -2
  48. package/src/admin-signin/panel/passkeys.ts +245 -0
  49. package/src/admin-signin/panel/view.ts +63 -1
  50. package/src/admin-signin/types.ts +62 -11
  51. 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 three tables, and only the
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 all three already (migrations 0001, 0002, 0003, 0005 and 0006): it needs NO migration, and `IF NOT EXISTS` leaves its tables and
6
- -- rows as they are if it runs this file anyway. A game that has the player sign-in's d1.sql has the first two: this file adds `admin_logins`.
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
- export function createD1AdminSignin(db: D1Like, tables: D1AdminSigninTables = {}): AdminSigninStore {
38
- const T = { requests: tables.requests ?? "signin_requests", sessions: tables.sessions ?? "sessions", logins: tables.logins ?? "admin_logins" };
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
- // 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
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
- export function createFirestoreAdminSignin(db: FirestoreAdminSigninLike, collections: { requests?: string; sessions?: string; logins?: string } = {}): AdminSigninStore {
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 deleteAll([...old, ...ended, ...logins]);
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. (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
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 function adminSigninStoreConformance(make: () => Promise<AdminSigninHarness>): AdminSigninConformanceCase[] {
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
- // 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).
356
- return s ? { address: s.address, createdAt: s.createdAt, via: s.via === "wallet" || s.via === "nanoterra" ? s.via : "unknown" } : null;
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 mine = berryId !== undefined || [`${base}/start`, `${base}/check`, `${base}/signout`, `${base}/nautilus`].includes(path) || (nanchatOn && path === `${base}/nanchat`);
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); 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
  //
@@ -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";