@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0 (2026-11-01)
4
+
5
+ - **The camera's distance on one curve (`createZoom`, `listenZoom` in `@nano133/nanoterra/input`):** one number, the camera's distance from the eyes, moved by
6
+ the mouse wheel, a trackpad's scroll or pinch, a touch pinch and the + and − keys, with nano133.com's island feel. A notch moves `0.35 + 0.14 × distance`
7
+ metres (100 px of scroll, a line 16 px, a page 400 px, a trackpad pinch five times a scroll), and every bit of a scroll counts: Echo Island's dead zone and
8
+ speed limit dropped most of a quick spin, and its wheel needed about five times the spins. The last notch in goes to the eyes, the first notch out past the
9
+ switch. The touch pinch is the same curve as a ratio: twice the spread divides `distance + 2.5` by 2^1.3 (`pinchStart`, `pinch`, `pinchEnd`; the game wires
10
+ the fingers). `createZoom(range, { read, write })` keeps the distance in the game's own storage (`read` at first use, never on the server), eases it
11
+ (`step(dt, calm)`, at once with reduced motion), and has `set`, `by`, `eyes` and `subscribe`. `listenZoom(zoom, canvas, { skip })` stops the page's scroll
12
+ over the canvas and leaves the browser's zoom keys and a field alone. The pure parts (`wheelNotches`, `zoomedBy`, `pinchedTo`) are exported. About 1.0 kB gzip.
13
+
14
+ ## 0.12.0 (2026-11-01)
15
+
16
+ - **A passkey for the owner (`createAdminSignin({ passkeys: true })`): the owner's LINKED passkeys open /admin.** A passkey is only a key to /admin; the money stays
17
+ in the owner's wallet. A link counts only when both sides prove their key: `POST /api/admin-signin/passkey/link` needs a live **wallet** admin session of the
18
+ owner AND the passkey's own signature of "Link this passkey to the ADMIN of <host>: sign in as <address> at <ms>". A linked passkey then signs "ADMIN of <host>:
19
+ sign in as ..." at `POST /api/admin-signin/passkey` (the person check and the start limit, like `start`) and gets a new admin session (`via: "passkey"`, one day,
20
+ the admin cookie, a row of the sign-in list). Never the host alone: a player's passkey signature never opens /admin. Each signature counts once (the admin's own
21
+ table) within 5 minutes. At most 10 passkeys per owner (`ADMIN_PASSKEY_LIMIT`); a passkey's session can list them (`POST /passkeys`) but not add or remove one;
22
+ `POST /passkey/unlink` (wallet only) removes a link and ends that passkey's sessions. Off by default: no route, and no passkey session opens /admin.
23
+ - **`session(request)` reads the link on every request** with the CURRENT owner: a "passkey" session comes back as `{ address, createdAt, via: "passkey", linkedTo:
24
+ owner }` only while its link exists, else as no session (401). A removed link, a new owner address, or the passkeys turned off end its access at the next request.
25
+ - **The guard's ONE new rule (Wes, 2026-11-01):** `AdminSession` has an optional `linkedTo`; check 3 accepts `via: "passkey"` only when `linkedTo` is a text, and
26
+ check 4 then compares `linkedTo` with the owner (exact text) instead of the address. Every other `via` is refused as before; a wallet session is judged by its
27
+ address (its `linkedTo` is not read). "DO NOT LOOSEN" stays. The grid test proves the kit is at least as strict as the starter for every input except a
28
+ "passkey" session with `linkedTo === owner`.
29
+ - Stores: optional passkey methods on `AdminSigninStore` (`createSession`, `linkPasskey` → "linked" | "exists" | "full", `unlinkPasskey`, `passkeys`, `linked`,
30
+ `useOnce`; `clean` drops used signatures); memory, D1 and Firestore have them, and `adminSigninStoreConformance(make, { passkeys })` has four new cases. **D1
31
+ migration** for a game with the tables already (the starter: `migrations/0009_admin_passkeys.sql`): `admin_passkeys (owner, address, created_at, PRIMARY KEY
32
+ (owner, address))` and `admin_once (key PRIMARY KEY, until)`, now at the end of `/admin-signin/d1.sql`. Without it the wallet sign-in and `clean()` work as before.
33
+ Firestore: `admin_passkeys/{owner}` (one document per owner) and `admin_once/{key}` (TTL on `deleteAt`).
34
+ - The panel: `adminSignIn({ passkeys: true, scheme, name })` adds a **Passkey** tile after the wallets; `ownerPasskeys({ scheme, name })` is the **Owner passkeys**
35
+ card (the list, Add a passkey and Remove, both only with a wallet session) for the new `mountAdmin` option `cards` (cards of the game's own in Settings,
36
+ `{ el, refresh? }`, `AdminCard`). The passkey code and the purse load only when a passkey is used. The admin icons have a `key`.
37
+ - Sizes (gzip, size test): `/admin-signin` 6.9 kB (ceiling 7.3 kB), `/admin-signin/d1` 1.9 kB (1.95 kB), `/admin-signin/firestore` 2.2 kB (2.3 kB),
38
+ `/admin-signin/panel` 9.3 kB (9.7 kB).
39
+
3
40
  ## 0.11.0 (2026-11-01)
4
41
 
5
42
  - **New path `@nano133/nanoterra/admin-signin`: the admin's wallet sign-in, on one fixed route in every game, `/api/admin-signin`.** The game starter's admin
package/README.md CHANGED
@@ -69,14 +69,14 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
69
69
  | `@nano133/nanoterra/looks` | the looks ladder | no | 1.9 kB (all), 0.9 kB (`checkLooks` alone) |
70
70
  | `@nano133/nanoterra/character` | the builder and `createCharacter` (it includes the record), with the three sockets | yes | 21.8 kB (all, with the sockets and `createLookPreview` of 0.3.0; 20.4 kB on 0.2.0), 17.8 kB (`createCharacter` alone, measured on 0.2.0) |
71
71
  | `@nano133/nanoterra/model` | the 3D model check: a `.glb` against the island's limits or a cosmetic's (since 0.3.0) | no | 8.8 kB |
72
- | `@nano133/nanoterra/input` | let go of held keys and sticks when the player leaves the page, and pause: `releaseWhenLost`, `pauseWhenLeft`, `swallowsKeyup` (since 0.3.1) | no | 0.5 kB |
72
+ | `@nano133/nanoterra/input` | let go of held keys and sticks when the player leaves the page, and pause: `releaseWhenLost`, `pauseWhenLeft`, `swallowsKeyup` (since 0.3.1); the camera's distance on one curve: `createZoom`, `listenZoom` (since 0.13.0) | no | 0.5 kB, 1.0 kB more with the zoom |
73
73
  | `@nano133/nanoterra/admin` | the admin of a game: the guard, the handler, the store type (server code; since 0.2.0) | no | 3.7 kB |
74
74
  | `@nano133/nanoterra/admin/d1`, `/admin/firestore` | the admin store for D1 (the starter's tables) or Firestore (a small interface, no Firebase dependency): `createD1Store`, `createFirestoreStore` (since 0.2.0) | no | 1.5 kB each |
75
75
  | `@nano133/nanoterra/admin/panel` | the admin page, drawn in the browser into one element, and its stylesheet `@nano133/nanoterra/admin/style.css` (since 0.2.0) | no | 18.9 kB with the tabbed layout (10.6 kB on 0.3.0, 8.5 kB on 0.2.0) + 5.9 kB of CSS (minified) |
76
76
  | `@nano133/nanoterra/admin/conformance` | test cases that a game runs against its own admin (a dev dependency in practice; since 0.2.0) | no | n/a |
77
- | `@nano133/nanoterra/admin-signin` | the admin's wallet sign-in on one fixed route, `/api/admin-signin`: the routes, `session(request)` for the admin handler, "Sign out everywhere", the sign-in list, a memory store (server code; it needs `@nano133/signin`; since 0.11.0) | no | 5.5 kB without `@nano133/signin` |
78
- | `@nano133/nanoterra/admin-signin/d1`, `/admin-signin/firestore` | its store on D1 (the starter's own tables; a new game adds `/admin-signin/d1.sql`) or Firestore (a small interface, no Firebase dependency) (since 0.11.0) | no | 1.4 kB, 1.7 kB |
79
- | `@nano133/nanoterra/admin-signin/panel` | the admin's sign-in in the browser, for `mountAdmin`'s `signIn` and `signOut`: `adminSignIn()`, `adminSignOut()`, with its own QR code; its stylesheet `@nano133/nanoterra/admin-signin/style.css` is the player sheet's (since 0.11.0) | no | 6.8 kB |
77
+ | `@nano133/nanoterra/admin-signin` | the admin's wallet sign-in on one fixed route, `/api/admin-signin`: the routes, `session(request)` for the admin handler, "Sign out everywhere", the sign-in list, a memory store, and (since 0.12.0, `passkeys: true`) the owner's linked passkeys (server code; it needs `@nano133/signin`; since 0.11.0) | no | 6.9 kB without `@nano133/signin` |
78
+ | `@nano133/nanoterra/admin-signin/d1`, `/admin-signin/firestore` | its store on D1 (the starter's own tables; a new game adds `/admin-signin/d1.sql`) or Firestore (a small interface, no Firebase dependency) (since 0.11.0) | no | 1.9 kB, 2.2 kB |
79
+ | `@nano133/nanoterra/admin-signin/panel` | the admin's sign-in in the browser, for `mountAdmin`'s `signIn` and `signOut`: `adminSignIn()`, `adminSignOut()`, with its own QR code, and (since 0.12.0) the Passkey tile and the `ownerPasskeys()` card (the passkey code loads only at a passkey's use); its stylesheet `@nano133/nanoterra/admin-signin/style.css` is the player sheet's (since 0.11.0) | no | 9.3 kB |
80
80
  | `@nano133/nanoterra/admin-signin/conformance` | the store's rules as test cases, for a store a game writes itself (since 0.11.0) | no | n/a |
81
81
  | `@nano133/nanoterra/friends` | the game's own friends list: the handler, the rules, a memory store (server code; since 0.6.0) | no | 3.7 kB |
82
82
  | `@nano133/nanoterra/friends/d1`, `/friends/firestore` | the friends store for D1 (with the migration `/friends/d1.sql`) or Firestore (a small interface, no Firebase dependency): `createD1Friends`, `createFirestoreFriends` (since 0.6.0) | no | 3.0 kB each |
@@ -291,6 +291,31 @@ pauseWhenLeft(() => { game.paused = true; resumeCard.hidden = false; });
291
291
  - It returns the function that takes its listeners away. It names no package and reads only plain browser events. The same code runs in nano133.com's island and rooms,
292
292
  and in the [game starter](https://github.com/nano133-com/nano-game-starter).
293
293
 
294
+ ## The camera's distance (`@nano133/nanoterra/input`; since 0.13.0)
295
+
296
+ One number, the camera's distance from the eyes, moved by the mouse wheel, a trackpad's two-finger scroll or pinch, a two-finger pinch on a touch screen
297
+ and the + and − keys, along ONE curve: nano133.com's island feel. Each notch moves `0.35 + 0.14 × distance` metres, so a notch feels the same near and
298
+ far, and every bit of a scroll counts (Echo Island's wheel had a dead zone and a speed limit, dropped most of a quick spin and needed about five times the
299
+ spins). The last notch in goes to the eyes and the first notch out goes past the switch, so the camera never rests half inside the head.
300
+
301
+ ```ts
302
+ import { createZoom, listenZoom } from "@nano133/nanoterra/input";
303
+
304
+ const zoom = createZoom({ max: 10, start: 5.2, first: 0.6 }, { read: () => saved(), write: (d) => save(d) });
305
+ listenZoom(zoom, renderer.domElement, { skip: () => paused }); // the wheel over the canvas, + and − on the window
306
+ // each frame: the distance eases to the one asked for (at once with reduced motion)
307
+ const dist = zoom.step(dt, lessMotion);
308
+ const firstPerson = zoom.eyes();
309
+ ```
310
+
311
+ - `read` is called the first time the distance is asked for (never on the server); `write` on every change: put a delay in it if your storage is slow.
312
+ - `listenZoom` stops the page's scroll and zoom over the canvas. A key with ctrl, alt or the command key (the browser's zoom) and a key typed in a field are
313
+ left alone; `skip(e)` keeps any other event from the zoom (a key the player is binding).
314
+ - The touch pinch is yours to wire, because the fingers are also your stick and your look: `pinchStart(spread)` when the second finger lands, `pinch(spread)`
315
+ as they move, `pinchEnd()`. Twice the spread divides `distance + 2.5` by 2^1.3, wherever the camera is: the wheel's curve.
316
+ - `set(d)` for a setting (a slider, a First person card), `subscribe(on)` for a menu that shows the distance. The pure parts (`wheelNotches`, `zoomedBy`,
317
+ `pinchedTo`) are there for a test.
318
+
294
319
  ## The sign-in ticket
295
320
 
296
321
  `@nano133/nanoterra/ticket` reads and checks a sign-in ticket in the player's browser. It is for a game on a **static site**: no server, no secret.
@@ -629,10 +654,9 @@ store.signOutEverywhere = signin.signOutEverywhere`, see "The admin's sign-in"),
629
654
 
630
655
  - **A second signature for a write.** The only server write the admin makes is the sign-in switch (and the Shelf never writes), so asking the owner to sign again for each press adds friction for
631
656
  little gain. A later version could ask for it if the admin ever writes more.
632
- - **An optional passkey for the admin, bound to the domain.** A passkey (WebAuthn) belongs to the domain it was made for, so a sign-in request that a stranger relays to the owner from another
633
- page or link **cannot be answered with it**: the browser will not use the passkey on another domain. That is the strongest fix for the relay of an admin wallet sign-in. The wallet text names
634
- the admin now, but a wallet cannot tell where a request started; a passkey can. Later, not now: the owner's wallet signature would stay as the way to make the first admin login or to
635
- recover, and the passkey would be a second, optional way that cannot be relayed.
657
+ - **An optional passkey for the admin, bound to the domain.** Built in 0.12.0: see "A passkey for the owner" under "The admin's sign-in". A passkey (WebAuthn) belongs to the domain it was
658
+ made for, so a sign-in request that a stranger relays to the owner from another page cannot be answered with it. The owner's wallet stays the way to make the first admin login, to link a
659
+ passkey and to recover.
636
660
 
637
661
  ### The panel
638
662
 
@@ -658,7 +682,8 @@ real tabs (the arrow keys, Home and End work); every tab is made once, so a draf
658
682
  A visitor who is signed out gets the game's sign-in; a wrong wallet, a Nanoterra sign-in and a missing owner address each get their own screen and see no data (a sign-out
659
683
  takes the drawn data off the page). Options: `api` (the handler's base path), `noAddressHelp`, `lookPicture(look)` (your own picture of a look for the Shelf), `repoHelp`,
660
684
  `loginsNote`, `playerPasskeys` (the game offers its players a passkey: on `*.workers.dev` the banner says that passkeys turn on with the game's own
661
- domain) and `fetch`.
685
+ domain), `cards` (since 0.12.0: cards of the game's own for Settings, after Sign-ins, `{ el, refresh? }`; `refresh()` runs at each sign-in; the owner's passkeys
686
+ card is one) and `fetch`.
662
687
 
663
688
  **The Shelf** shows the looks your game sells now in a grid of cards: a big picture on a soft tint of the look's colour, the price with its step on the ladder ("25¢ ·
664
689
  statement"), what it has sold and its share of the sales, its slots as chips; filters (All, For sale, Free, Not sold yet) and a sort (Best selling, price, name, the
@@ -783,13 +808,14 @@ minutes**, is delivered for **one more minute**, and works **once**; only the br
783
808
  **NanChat is OFF by default.** Its text must start "Login to <host>" and **cannot name the ADMIN**, so the owner who is asked to sign cannot tell an admin sign-in from a game
784
809
  sign-in. A start with `way: "nanchat"` is refused: "NanChat cannot open the admin yet. Use Berry Wallet or Nautilus." A game may turn it on with `nanchat: true` (and the
785
810
  panel's `nanchat: true`): it then signs "Login to <host>" as a player's NanChat sign-in does, with the same admin checks, and the panel says that its text cannot say ADMIN.
786
- **Berry Wallet is first** in every list of wallets. **No passkey opens /admin** yet.
811
+ **Berry Wallet is first** in every list of wallets. A passkey opens /admin only when the owner linked it (`passkeys: true`, see "A passkey for the owner" below).
787
812
 
788
813
  **The two logins stay apart, on the server.** The admin login is its own cookie, `admin` (`__Host-admin` on https; HttpOnly, SameSite=Lax, **one day**), beside the player's
789
814
  `session`, so the game and /admin can have different people signed in at once. The database keeps the SHA-256 of `"admin:"` and the token (`adminKey`), and the player's
790
815
  session its plain hash: a player's token put in the admin cookie opens nothing, a player's starter copied to `starter-admin` finishes nothing, and an admin request never
791
816
  makes a player's login (the store reads only its own scope's rows). **Only the game's own address (`owner`) makes an admin login**; the guard (`adminAccess`) decides again
792
- on every call of /admin. `session(request)` gives `{ address, createdAt, via }` (a stored word other than `"wallet"` or `"nanoterra"` is `"unknown"`: refused).
817
+ on every call of /admin. `session(request)` gives `{ address, createdAt, via }` (a stored word other than `"wallet"`, `"nanoterra"` or `"passkey"` is `"unknown"`: refused;
818
+ a `"passkey"` session also carries `linkedTo`, only while its link exists: see below).
793
819
 
794
820
  **What it keeps.** Each admin login writes one row of the sign-in list: the time, the country of Cloudflare's country header (two letters, or none) and a short browser
795
821
  name from fixed lists ("Chrome on macOS"): no address, no IP and no browser text. A row is kept 30 days. `signOutEverywhere()` deletes every admin login and ends every admin
@@ -817,6 +843,79 @@ check every 2 seconds, and `done()` once the owner has signed in. Options: `base
817
843
  `session: adminSignin.session`, and in `admin.ts` `signIn: adminSignIn(...)`, `signOut: adminSignOut()`. The player's sign-in then needs no `sharedPaths`, and
818
844
  `src/worker/signin.ts` and `src/client/ui/signin.ts` go. Old requests started on `/api/signin` with `scope: "admin"` are 2 minutes long: none outlive the deploy.
819
845
 
846
+ ### A passkey for the owner (since 0.12.0)
847
+
848
+ The owner may **link passkeys** to the game's address (at most 10, `ADMIN_PASSKEY_LIMIT`) and then open /admin with Face ID, Touch ID or the device PIN, without
849
+ the wallet. **A passkey is only a key to /admin: the money stays in the owner's wallet.** It is off unless the game turns it on.
850
+
851
+ **The security rule, in plain words.** A link counts only when each side proves its key, as nano133.com's account links do:
852
+
853
+ 1. **Only the owner's wallet writes a link.** `POST {base}/passkey/link` needs a live admin session made by the **wallet** of the game's own address. A passkey's
854
+ session cannot add a passkey, and neither can a player's login, a Nanoterra sign-in or another wallet.
855
+ 2. **The passkey proves its key** in the same call: its purse signs `Link this passkey to the ADMIN of <host>: sign in as <address> at <ms>`.
856
+ 3. **A linked passkey opens /admin** with its own signature of `ADMIN of <host>: sign in as <address> at <ms>`. It never names the host alone: a player's passkey
857
+ signature (`<host>: sign in as ...`) never opens /admin, a link signature is not a sign-in, and a sign-in signature links nothing. Each signature counts
858
+ **once** (the admin's own table) and within **5 minutes**.
859
+ 4. **The link is read on every request**, with the **current** owner address. Remove the link, or change the game's address, and the passkey's access ends at
860
+ the next request; a removal also deletes that passkey's admin sessions. The guard's one new rule (Wes, 2026-11-01): a session with `via: "passkey"` opens
861
+ /admin only with `linkedTo` equal to the owner address, and `session(request)` sets `linkedTo` only after it read the link. Every other check (the owner
862
+ address, one day, the clock) is the same as for the wallet.
863
+
864
+ ```ts
865
+ // The Worker: the same createAdminSignin, with passkeys on
866
+ const adminSignin = createAdminSignin({ store: createD1AdminSignin(env.DB), owner: receiveAddress(env), passkeys: true, limit, visitor, personCheck });
867
+
868
+ // The admin page: the Passkey tile in the sign-in, and the "Owner passkeys" card in Settings
869
+ import { mountAdmin } from "@nano133/nanoterra/admin/panel";
870
+ import { adminSignIn, adminSignOut, ownerPasskeys } from "@nano133/nanoterra/admin-signin/panel";
871
+ import { SCHEME } from "./scheme"; // the game's FROZEN passkey scheme: the same one the player sheet takes
872
+ mountAdmin(el, {
873
+ looks,
874
+ signIn: adminSignIn({ personCheck, passkeys: true, scheme: SCHEME, name: "My Game" }),
875
+ signOut: adminSignOut(),
876
+ cards: [ownerPasskeys({ scheme: SCHEME, name: "My Game" })],
877
+ });
878
+ ```
879
+
880
+ | Route (with `passkeys: true`) | Who may call it | What it does |
881
+ |---|---|---|
882
+ | `POST {base}/passkey/link` `{ address, at, signature }` | a live **wallet** admin session of the owner | links the passkey that signed the link text: `{ ok, address, passkeys, limit, via, canChange, you }`; 409 `full` or `exists` |
883
+ | `POST {base}/passkey/unlink` `{ address }` | a live **wallet** admin session of the owner | removes the link and ends that passkey's admin sessions: `{ ok, removed, passkeys, ... }` |
884
+ | `POST {base}/passkeys` | any session the guard accepts | `{ passkeys: [{ address, createdAt }], limit, via, canChange, you }` (`canChange` only for the wallet) |
885
+ | `POST {base}/passkey` `{ address, at, signature, turnstile? }` | the admin page | the passkey's ADMIN signature (the person check and the start limit, like `start`); 403 `not_linked` unless linked; else a new admin session `{ via: "passkey" }` of one day, the sign-in list row and the admin cookie: `{ status: "signedin", address, linkedTo }` |
886
+
887
+ All four are POSTs from the game's own page (`Origin` checked) and are not routes at all while `passkeys` is off. The list, a link and a removal count 30 a minute
888
+ per IP address; a link and a passkey sign-in also count with the starts (`limit`, 20 in 10 minutes per visitor). A refusal of a link from a passkey's session has
889
+ `reason: "wallet_only"`. "Sign out everywhere" ends the passkeys' sessions too, and keeps the links (Remove ends one).
890
+
891
+ **The panel.** `adminSignIn({ passkeys: true, scheme, name })` adds a **Passkey** tile after the wallets ("Use my passkey"). `ownerPasskeys({ scheme, name })` is
892
+ a card for `mountAdmin`'s new `cards` option (cards of the game's own in Settings, after Sign-ins; each one's `refresh()` runs at every sign-in): the list, **Add a
893
+ passkey** ("Make a new passkey" or "Use a passkey I have") and **Remove** (two presses), both shown only with a wallet session; a passkey's session sees "Sign in
894
+ with your wallet to add or remove a passkey." The passkey code and the purse load only when a passkey is used. Text 16 px, keys 48 px. The passkey comes from the
895
+ game's frozen scheme (the player sheet's), so a passkey the owner also uses to play may be linked too: its PLAYER signature still opens nothing here.
896
+
897
+ **The store** needs the passkey methods (`createSession`, `linkPasskey`, `unlinkPasskey`, `passkeys`, `linked`, `useOnce`; the package's three stores have them, and
898
+ `createAdminSignin({ passkeys: true })` refuses a store without them); `adminSigninStoreConformance(make, { passkeys: true })` proves them. **D1** keeps them in two
899
+ new tables at the end of `d1.sql`. **A starter-shaped game adds this migration** (the starter: `migrations/0009_admin_passkeys.sql`); a game that already ran
900
+ `d1.sql` before 0.12.0 adds the same two statements:
901
+
902
+ ```sql
903
+ CREATE TABLE IF NOT EXISTS admin_passkeys (
904
+ owner TEXT NOT NULL,
905
+ address TEXT NOT NULL,
906
+ created_at INTEGER NOT NULL,
907
+ PRIMARY KEY (owner, address)
908
+ );
909
+ CREATE TABLE IF NOT EXISTS admin_once (
910
+ key TEXT PRIMARY KEY,
911
+ until INTEGER NOT NULL
912
+ );
913
+ ```
914
+
915
+ Without it, the wallet sign-in and `clean()` work as before (the clean-up skips the missing table); only `passkeys: true` needs it. **Firestore** keeps one document
916
+ per owner in `admin_passkeys/{owner}` (`{ owner, passkeys: [{ address, createdAt }] }`, no `deleteAt`) and the used signatures in `admin_once/{key}` (`{ until,
917
+ deleteAt }`: add it to the TTL policy). Other names: `createD1AdminSignin(db, { passkeys, once })`, `createFirestoreAdminSignin(db, { passkeys, once })`.
918
+
820
919
  ## The friends list (`/friends`; since 0.6.0)
821
920
 
822
921
  A Friends key for the game, and a list behind it. The admin's **Game** card chooses which list (`friends` in the settings; Nanoterra by default):
@@ -1522,7 +1621,6 @@ counter** as they are, and the page needs no change for the server part.
1522
1621
  - First-person arms, swimming, climbing and the room's other moves: only idle, walk, run, jump and wave, and the emotes (`/emotes`, which sit too).
1523
1622
  - Shared geometry for many characters at once, and a react-three-fiber wrapper.
1524
1623
  - `WebGPURenderer`: the ink is a GLSL `ShaderMaterial`, so use `WebGLRenderer`.
1525
- - A passkey for the admin: the owner's linked passkey comes in a later version. Today only a wallet signature opens /admin.
1526
1624
  - A creator option to accept Berry Wallet only.
1527
1625
  - Move money. The shop (`/shop`) reads the Nano ledger to see a payment to the creator's address; no part of the package holds a key or sends.
1528
1626
 
package/ai/SPEC.md CHANGED
@@ -275,13 +275,20 @@ do not change what the wallet signs.
275
275
  4. **The page.** `mountAdmin(el, { looks, signIn: adminSignIn({ personCheck }), signOut: adminSignOut() })` from `@nano133/nanoterra/admin-signin/panel`, with
276
276
  `@nano133/nanoterra/admin-signin/style.css`.
277
277
  5. **NanChat stays off** unless the owner asks for it: its text cannot name the ADMIN, so the owner cannot tell an admin sign-in from a game sign-in. To turn it on, pass
278
- `nanchat: true` to `createAdminSignin` AND to `adminSignIn`. No passkey opens /admin yet.
279
- 6. **Tests.** Run `adminSigninConformance` (`@nano133/nanoterra/admin/conformance`) on the game's own chain (its player sign-in and the admin's), and `adminConformance` with
280
- the admin handler and `adminSignin.session`.
278
+ `nanchat: true` to `createAdminSignin` AND to `adminSignIn`.
279
+ 6. **The owner's passkeys (only if the owner asks; since 0.12.0).** A passkey is only a key to /admin: the money stays in the owner's wallet. Pass `passkeys: true` to
280
+ `createAdminSignin`; give `adminSignIn` the same `passkeys: true` with the game's frozen `scheme` (the player sheet's) and `name`, and give `mountAdmin`
281
+ `cards: [ownerPasskeys({ scheme, name })]`. D1: add the two tables at the end of `@nano133/nanoterra/admin-signin/d1.sql` as a new migration (the starter:
282
+ `migrations/0009_admin_passkeys.sql`, `admin_passkeys` and `admin_once`); Firestore: add `admin_once` to the TTL policy. Only a WALLET admin session of the owner links
283
+ or removes a passkey (at most 10), each passkey proves its key when it is linked, and a passkey's login counts only while its link to the current owner address
284
+ exists. Never write your own passkey check for /admin, never let a passkey session link another passkey, and never give `linkedTo` to a session yourself.
285
+ 7. **Tests.** Run `adminSigninConformance` (`@nano133/nanoterra/admin/conformance`) on the game's own chain (its player sign-in and the admin's), and `adminConformance` with
286
+ the admin handler and `adminSignin.session`. A store of your own runs `adminSigninStoreConformance(make, { passkeys: true })` when the passkeys are on.
281
287
 
282
288
  **Check:** on the game's public https address, Berry Wallet shows "URI: https://<host>/admin" and Nautilus "ADMIN of <host>"; the owner's wallet opens /admin; another
283
289
  wallet gets "That wallet is not this game's address"; the player's login (the owner's own address too) does not open /admin; a NanChat start says "NanChat cannot open the
284
- admin yet."
290
+ admin yet." With passkeys on: the owner's wallet adds a passkey in Settings, Owner passkeys; that passkey then opens /admin from the Passkey tile and cannot add
291
+ or remove a passkey; after Remove it opens nothing.
285
292
 
286
293
  ## 15. Do not
287
294
 
@@ -2,8 +2,16 @@
2
2
  export type AdminSession = {
3
3
  address: string;
4
4
  createdAt: number;
5
- /** "wallet" is a signature made with the address's key. Anything else (a sign-in from Nanoterra names an address but proves no key) never opens /admin. */
5
+ /**
6
+ * "wallet" is a signature made with the address's key. "passkey" is a signature by a passkey that the owner linked: it counts only with `linkedTo`.
7
+ * Anything else (a sign-in from Nanoterra names an address but proves no key) never opens /admin.
8
+ */
6
9
  via: string;
10
+ /**
11
+ * For a "passkey" session only: the owner address its link points to, set by the sign-in only after it read the link on THIS request. The guard compares
12
+ * it with the owner instead of `address` (the passkey's own). A session of any other `via` is judged by its `address`; this field is not read.
13
+ */
14
+ linkedTo?: string;
7
15
  };
8
16
  export type AdminAccessInput = {
9
17
  /** The game's own address, in the form "nano_" and 60 characters, lowercase. A host passes null when none is set (or the example address is). */
@@ -4,6 +4,7 @@
4
4
  // DO NOT LOOSEN THIS. It is the game starter's `adminGuard` (src/worker/admin.ts), with the same five checks in the same order, and it may only
5
5
  // refuse MORE than the starter does, never less (a test runs both over a grid of inputs and proves it). Deny by default: anything this function
6
6
  // does not understand is a refusal, and a fault inside it is a refusal too.
7
+ // The ONE rule Wes approved on 2026-11-01 (the owner's linked passkey): a "passkey" session opens /admin only with `linkedTo` === the owner (checks 3 and 4).
7
8
  /** An admin login counts for one day; then the owner signs in again. */
8
9
  export const ADMIN_SESSION_MS = 24 * 60 * 60_000;
9
10
  /** A login made in the future by this much or more is not believed (clocks of two servers differ by seconds, not by minutes). */
@@ -43,11 +44,18 @@ function decide(input) {
43
44
  const { address, createdAt, via } = session;
44
45
  if (typeof address !== "string" || typeof via !== "string" || typeof createdAt !== "number")
45
46
  return refuse(401, "Sign in with your Nano address.");
46
- // 3. It was made with a wallet signature. A sign-in from Nanoterra names an address but proves no key.
47
- if (via !== "wallet")
47
+ // 3. It was made with a wallet signature, or (the one rule of 2026-11-01) by a passkey whose link the sign-in read on this request. A sign-in from
48
+ // Nanoterra names an address but proves no key.
49
+ let linkedTo;
50
+ if (via === "passkey") {
51
+ linkedTo = session.linkedTo;
52
+ if (typeof linkedTo !== "string")
53
+ return refuse(403, "Sign in with your Nano wallet to open this page.", { reason: "nanoterra" });
54
+ }
55
+ else if (via !== "wallet")
48
56
  return refuse(403, "Sign in with your Nano wallet to open this page.", { reason: "nanoterra" });
49
- // 4. It is the owner's address (the same text, no other form).
50
- if (address !== owner)
57
+ // 4. It is the owner's address (the same text, no other form): a wallet's own address, or the address a passkey's link points to.
58
+ if ((via === "passkey" ? linkedTo : address) !== owner)
51
59
  return refuse(403, "Only the game's own address can open this page.", { reason: "other_address", signedInAs: address });
52
60
  // 5. It is not older than the limit (one day at most), and not from the future.
53
61
  const age = now - createdAt;
@@ -0,0 +1,5 @@
1
+ /** A card of the game's own in the Settings tab: its element, made once, and what to do each time the owner signs in (read its data again). */
2
+ export type AdminCard = {
3
+ el: HTMLElement;
4
+ refresh?: () => void | Promise<void>;
5
+ };
@@ -0,0 +1,3 @@
1
+ // A card of the game's own for the admin panel's Settings tab (`mountAdmin`'s `cards`). Its own file, so a card made elsewhere (the owner's passkeys, in
2
+ // "@nano133/nanoterra/admin-signin/panel") names the type without reaching the whole panel.
3
+ export {};
@@ -149,6 +149,15 @@ declare const SHAPES: {
149
149
  d: string;
150
150
  "stroke-width": number;
151
151
  }])[];
152
+ key: ([string, {
153
+ d: string;
154
+ }] | [string, {
155
+ cx: number;
156
+ cy: number;
157
+ r: number;
158
+ fill: string;
159
+ stroke: string;
160
+ }])[];
152
161
  };
153
162
  export type IconName = keyof typeof SHAPES;
154
163
  /** An icon of `size` CSS pixels (default 20). */
@@ -31,6 +31,7 @@ const SHAPES = {
31
31
  gear: [["path", { d: "M19.23 9.65 21.77 9.86V14.14L19.23 14.35 18.77 15.45 20.42 17.39 17.39 20.42 15.45 18.77 14.35 19.23 14.14 21.77H9.86L9.65 19.23 8.55 18.77 6.61 20.42 3.58 17.39 5.23 15.45 4.77 14.35 2.23 14.14V9.86L4.77 9.65 5.23 8.55 3.58 6.61 6.61 3.58 8.55 5.23 9.65 4.77 9.86 2.23H14.14L14.35 4.77 15.45 5.23 17.39 3.58 20.42 6.61 18.77 8.55Z" }], ["circle", { cx: 12, cy: 12, r: 3.2 }]],
32
32
  sparkle: [["path", { d: "M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6" }]],
33
33
  smile: [["circle", { cx: 12, cy: 12, r: 9 }], ["path", { d: "M8.2 14.2a4.6 4.6 0 0 0 7.6 0" }], ["path", { d: "M9 9.6h.01M15 9.6h.01", "stroke-width": 2.6 }]],
34
+ key: [["path", { d: "M14.5 9.5a4.5 4.5 0 1 1-1.3-3.2" }], ["path", { d: "M13.2 6.3 21 6.3M18 6.3v3M21 6.3v2.2" }], ["circle", { cx: 10, cy: 9.5, r: 1.4, fill: "currentColor", stroke: "none" }]],
34
35
  };
35
36
  /** An icon of `size` CSS pixels (default 20). */
36
37
  export function icon(name, size = 20) {
@@ -1,5 +1,5 @@
1
1
  export { mountAdmin } from "./mount.js";
2
- export type { MountedAdmin, MountOptions } from "./mount.js";
2
+ export type { AdminCard, MountedAdmin, MountOptions } from "./mount.js";
3
3
  export { ApiError, createApi } from "./api.js";
4
4
  export type { Api } from "./api.js";
5
5
  export { gameCard } from "./gameCard.js";
@@ -1,4 +1,5 @@
1
1
  import type { Look } from "../../looks/look.js";
2
+ import type { AdminCard } from "./card.js";
2
3
  import { type ThemeControl } from "./theme.js";
3
4
  export type MountOptions = {
4
5
  /** The game's looks now (its looks.json). The Shelf starts from them. */
@@ -22,9 +23,15 @@ export type MountOptions = {
22
23
  /** The game offers its players a passkey (`@nano133/nanoterra/player-signin`): on a preview address (`*.workers.dev`) the banner says that passkeys turn
23
24
  * on with the game's own domain. */
24
25
  playerPasskeys?: boolean;
26
+ /**
27
+ * Cards of the game's own for the Settings tab, after the Sign-ins card: for example the owner's passkeys (`ownerPasskeys()` of
28
+ * "@nano133/nanoterra/admin-signin/panel"). Each card is made once and kept for the life of the page; its `refresh()` runs each time the owner signs in.
29
+ */
30
+ cards?: readonly AdminCard[];
25
31
  /** The `fetch` to use (default: the page's). */
26
32
  fetch?: typeof fetch;
27
33
  };
34
+ export type { AdminCard };
28
35
  export type MountedAdmin = {
29
36
  /** Reads everything again and redraws. */
30
37
  reload(): Promise<void>;
@@ -139,6 +139,9 @@ export function mountAdmin(root, options) {
139
139
  const kpis = h("div", { class: "kpis", role: "group", "aria-label": "Your game in numbers" });
140
140
  const settings = h("div", { class: "settings-grid" });
141
141
  tabs.panels.settings.append(settings);
142
+ // The game's own cards read their data again at each sign-in (a new dashboard is built after every sign-in).
143
+ for (const c of options.cards ?? [])
144
+ void Promise.resolve(c.refresh?.()).catch(() => undefined);
142
145
  data.replaceChildren(top, banner, kpis, tabs.bar, ...Object.values(tabs.panels));
143
146
  const kpi = (cls, mark, label, value, sub, open, alert = false) => h("button", { class: `kpi kpi-${cls}${alert ? " is-alert" : ""}`, type: "button", onclick: open }, h("span", { class: "kpi-top" }, h("span", { class: "kpi-icon", "aria-hidden": "true" }, icon(mark, 18)), h("span", { class: "kpi-label" }, label)), h("strong", { class: "kpi-value" }, value), h("span", { class: "kpi-sub" }, sub));
144
147
  function about(s) {
@@ -176,7 +179,7 @@ export function mountAdmin(root, options) {
176
179
  orders.update(s);
177
180
  players.update(s);
178
181
  // The Game card is the same element at every redraw: moving it keeps its state.
179
- settings.replaceChildren(...[game, signinsCard(s, api, () => void load(), options.loginsNote), about(s)].filter((p) => p !== null));
182
+ settings.replaceChildren(...[game, signinsCard(s, api, () => void load(), options.loginsNote), ...(options.cards ?? []).map((c) => c.el), about(s)].filter((p) => p !== null));
180
183
  },
181
184
  };
182
185
  }
@@ -6,5 +6,7 @@ export type D1AdminSigninTables = {
6
6
  requests?: string;
7
7
  sessions?: string;
8
8
  logins?: string;
9
+ passkeys?: string;
10
+ once?: string;
9
11
  };
10
- export declare function createD1AdminSignin(db: D1Like, tables?: D1AdminSigninTables): AdminSigninStore;
12
+ export declare function createD1AdminSignin(db: D1Like, tables?: D1AdminSigninTables): Required<AdminSigninStore>;
@@ -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);
@@ -9,7 +10,7 @@
9
10
  // keeps its rows, and this adapter never reads, delivers, ends or deletes one. D1 has no transaction that reads and then writes, so each change is ONE
10
11
  // statement that holds its own check, and a delivery and its session are one batch (the session is written only when the delivery changed a row).
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
- import { ADMIN_LOGIN_KEEP_MS, ADMIN_WAYS } from "../types.js";
13
+ import { ADMIN_LOGIN_KEEP_MS, ADMIN_PASSKEY_LIMIT, ADMIN_WAYS } from "../types.js";
13
14
  const toRequest = (r) => ({
14
15
  id: r.rid ?? r.id,
15
16
  starter: r.starter,
@@ -22,8 +23,16 @@ const toRequest = (r) => ({
22
23
  });
23
24
  const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
24
25
  const changes = (res) => Number(res?.meta?.changes ?? 0);
26
+ /** True for D1's (and SQLite's) "no such table": a game that has not added the passkeys' tables. */
27
+ const noTable = (e) => /no such table/i.test(String(e?.message ?? e));
25
28
  export function createD1AdminSignin(db, tables = {}) {
26
- const T = { requests: tables.requests ?? "signin_requests", sessions: tables.sessions ?? "sessions", logins: tables.logins ?? "admin_logins" };
29
+ const T = {
30
+ requests: tables.requests ?? "signin_requests",
31
+ sessions: tables.sessions ?? "sessions",
32
+ logins: tables.logins ?? "admin_logins",
33
+ passkeys: tables.passkeys ?? "admin_passkeys",
34
+ once: tables.once ?? "admin_once",
35
+ };
27
36
  for (const n of Object.values(T))
28
37
  if (!NAME.test(n))
29
38
  throw new Error(`not a table name: ${n}`);
@@ -101,6 +110,52 @@ export function createD1AdminSignin(db, tables = {}) {
101
110
  db.prepare(`DELETE FROM ${T.sessions} WHERE expires_at < ? AND scope = 'admin'`).bind(now),
102
111
  db.prepare(`DELETE FROM ${T.logins} WHERE at < ?`).bind(now - ADMIN_LOGIN_KEEP_MS),
103
112
  ]);
113
+ // The used passkey signatures, on their own: a game without the passkeys' tables (they came in 0.12.0) still cleans the rest.
114
+ try {
115
+ await db.prepare(`DELETE FROM ${T.once} WHERE until <= ?`).bind(now).run();
116
+ }
117
+ catch (e) {
118
+ if (!noTable(e))
119
+ throw e;
120
+ }
121
+ },
122
+ async createSession(s) {
123
+ await db.prepare(`INSERT INTO ${T.sessions} (id_hash, address, created_at, expires_at, via, scope) VALUES (?, ?, ?, ?, ?, 'admin')`).bind(s.idHash, s.address, s.createdAt, s.expiresAt, s.via).run();
124
+ },
125
+ async linkPasskey(owner, address, at) {
126
+ // ONE statement holds the limit: the row is written only while the owner has fewer than the limit, and never twice.
127
+ const res = await db
128
+ .prepare(`INSERT INTO ${T.passkeys} (owner, address, created_at) SELECT ?, ?, ? WHERE (SELECT COUNT(*) FROM ${T.passkeys} WHERE owner = ?) < ? ON CONFLICT (owner, address) DO NOTHING`)
129
+ .bind(owner, address, at, owner, ADMIN_PASSKEY_LIMIT)
130
+ .run();
131
+ if (changes(res) === 1)
132
+ return "linked";
133
+ const had = await db.prepare(`SELECT 1 AS one FROM ${T.passkeys} WHERE owner = ? AND address = ?`).bind(owner, address).first();
134
+ return had ? "exists" : "full";
135
+ },
136
+ async unlinkPasskey(owner, address) {
137
+ // The link, and the passkey's admin sessions with it.
138
+ const done = await db.batch([
139
+ db.prepare(`DELETE FROM ${T.passkeys} WHERE owner = ? AND address = ?`).bind(owner, address),
140
+ db.prepare(`DELETE FROM ${T.sessions} WHERE scope = 'admin' AND via = 'passkey' AND address = ?`).bind(address),
141
+ ]);
142
+ return changes(done[0]) > 0;
143
+ },
144
+ async passkeys(owner) {
145
+ const rows = (await db.prepare(`SELECT address, created_at FROM ${T.passkeys} WHERE owner = ? ORDER BY created_at, address`).bind(owner).all()).results;
146
+ return rows.map((r) => ({ address: String(r.address), createdAt: Number(r.created_at) }));
147
+ },
148
+ async linked(owner, address) {
149
+ return (await db.prepare(`SELECT 1 AS one FROM ${T.passkeys} WHERE owner = ? AND address = ?`).bind(owner, address).first()) !== null;
150
+ },
151
+ async useOnce(key, ttlMs) {
152
+ const now = Date.now();
153
+ // A row past its time may be taken again (its signature's window is long over); a live one may not.
154
+ const res = await db
155
+ .prepare(`INSERT INTO ${T.once} (key, until) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET until = excluded.until WHERE ${T.once}.until <= ?`)
156
+ .bind(key, now + ttlMs, now)
157
+ .run();
158
+ return changes(res) === 1;
104
159
  },
105
160
  };
106
161
  }
@@ -42,8 +42,11 @@ export interface FirestoreAdminSigninLike {
42
42
  runTransaction<T>(fn: (tx: AdminSigninTransaction) => Promise<T>): Promise<T>;
43
43
  batch(): AdminSigninBatch;
44
44
  }
45
- export declare function createFirestoreAdminSignin(db: FirestoreAdminSigninLike, collections?: {
45
+ export type FirestoreAdminSigninCollections = {
46
46
  requests?: string;
47
47
  sessions?: string;
48
48
  logins?: string;
49
- }): AdminSigninStore;
49
+ passkeys?: string;
50
+ once?: string;
51
+ };
52
+ export declare function createFirestoreAdminSignin(db: FirestoreAdminSigninLike, collections?: FirestoreAdminSigninCollections): Required<AdminSigninStore>;