@nano133/nanoterra 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +84 -11
- package/ai/SPEC.md +11 -4
- package/dist/admin/guard.d.ts +9 -1
- package/dist/admin/guard.js +12 -4
- package/dist/admin/panel/card.d.ts +5 -0
- package/dist/admin/panel/card.js +3 -0
- package/dist/admin/panel/icons.d.ts +9 -0
- package/dist/admin/panel/icons.js +1 -0
- package/dist/admin/panel/index.d.ts +1 -1
- package/dist/admin/panel/mount.d.ts +7 -0
- package/dist/admin/panel/mount.js +4 -1
- package/dist/admin-signin/adapters/d1.d.ts +3 -1
- package/dist/admin-signin/adapters/d1.js +58 -3
- package/dist/admin-signin/adapters/firestore.d.ts +5 -2
- package/dist/admin-signin/adapters/firestore.js +74 -5
- package/dist/admin-signin/conformance.d.ts +8 -1
- package/dist/admin-signin/conformance.js +88 -3
- package/dist/admin-signin/handler.d.ts +7 -0
- package/dist/admin-signin/handler.js +134 -6
- package/dist/admin-signin/index.d.ts +2 -2
- package/dist/admin-signin/index.js +3 -2
- package/dist/admin-signin/memory.d.ts +6 -2
- package/dist/admin-signin/memory.js +47 -1
- package/dist/admin-signin/panel/index.d.ts +3 -0
- package/dist/admin-signin/panel/index.js +4 -2
- package/dist/admin-signin/panel/passkeys.d.ts +43 -0
- package/dist/admin-signin/panel/passkeys.js +202 -0
- package/dist/admin-signin/panel/view.d.ts +12 -0
- package/dist/admin-signin/panel/view.js +48 -1
- package/dist/admin-signin/types.d.ts +57 -7
- package/dist/admin-signin/types.js +20 -3
- package/package.json +1 -1
- package/src/admin/guard.ts +19 -5
- package/src/admin/panel/card.ts +5 -0
- package/src/admin/panel/icons.ts +1 -0
- package/src/admin/panel/index.ts +1 -1
- package/src/admin/panel/mount.ts +11 -1
- package/src/admin/panel/style.css +44 -0
- package/src/admin-signin/adapters/d1.sql +20 -3
- package/src/admin-signin/adapters/d1.ts +58 -5
- package/src/admin-signin/adapters/firestore.ts +73 -6
- package/src/admin-signin/conformance.ts +99 -3
- package/src/admin-signin/handler.ts +131 -4
- package/src/admin-signin/index.ts +7 -2
- package/src/admin-signin/memory.ts +43 -2
- package/src/admin-signin/panel/index.ts +6 -2
- package/src/admin-signin/panel/passkeys.ts +245 -0
- package/src/admin-signin/panel/view.ts +63 -1
- package/src/admin-signin/types.ts +62 -11
- package/src/player-signin/panel/style.css +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.0 (2026-11-01)
|
|
4
|
+
|
|
5
|
+
- **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
|
|
6
|
+
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
|
|
7
|
+
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>:
|
|
8
|
+
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,
|
|
9
|
+
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
|
|
10
|
+
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;
|
|
11
|
+
`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.
|
|
12
|
+
- **`session(request)` reads the link on every request** with the CURRENT owner: a "passkey" session comes back as `{ address, createdAt, via: "passkey", linkedTo:
|
|
13
|
+
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.
|
|
14
|
+
- **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
|
|
15
|
+
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
|
|
16
|
+
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
|
|
17
|
+
"passkey" session with `linkedTo === owner`.
|
|
18
|
+
- Stores: optional passkey methods on `AdminSigninStore` (`createSession`, `linkPasskey` → "linked" | "exists" | "full", `unlinkPasskey`, `passkeys`, `linked`,
|
|
19
|
+
`useOnce`; `clean` drops used signatures); memory, D1 and Firestore have them, and `adminSigninStoreConformance(make, { passkeys })` has four new cases. **D1
|
|
20
|
+
migration** for a game with the tables already (the starter: `migrations/0009_admin_passkeys.sql`): `admin_passkeys (owner, address, created_at, PRIMARY KEY
|
|
21
|
+
(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.
|
|
22
|
+
Firestore: `admin_passkeys/{owner}` (one document per owner) and `admin_once/{key}` (TTL on `deleteAt`).
|
|
23
|
+
- The panel: `adminSignIn({ passkeys: true, scheme, name })` adds a **Passkey** tile after the wallets; `ownerPasskeys({ scheme, name })` is the **Owner passkeys**
|
|
24
|
+
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,
|
|
25
|
+
`{ el, refresh? }`, `AdminCard`). The passkey code and the purse load only when a passkey is used. The admin icons have a `key`.
|
|
26
|
+
- 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),
|
|
27
|
+
`/admin-signin/panel` 9.3 kB (9.7 kB).
|
|
28
|
+
|
|
3
29
|
## 0.11.0 (2026-11-01)
|
|
4
30
|
|
|
5
31
|
- **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
|
@@ -74,9 +74,9 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
|
|
|
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 |
|
|
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.
|
|
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 |
|
|
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 |
|
|
@@ -629,10 +629,9 @@ store.signOutEverywhere = signin.signOutEverywhere`, see "The admin's sign-in"),
|
|
|
629
629
|
|
|
630
630
|
- **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
631
|
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.**
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
recover, and the passkey would be a second, optional way that cannot be relayed.
|
|
632
|
+
- **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
|
|
633
|
+
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
|
|
634
|
+
passkey and to recover.
|
|
636
635
|
|
|
637
636
|
### The panel
|
|
638
637
|
|
|
@@ -658,7 +657,8 @@ real tabs (the arrow keys, Home and End work); every tab is made once, so a draf
|
|
|
658
657
|
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
658
|
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
659
|
`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)
|
|
660
|
+
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
|
|
661
|
+
card is one) and `fetch`.
|
|
662
662
|
|
|
663
663
|
**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
664
|
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 +783,14 @@ minutes**, is delivered for **one more minute**, and works **once**; only the br
|
|
|
783
783
|
**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
784
|
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
785
|
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.
|
|
786
|
+
**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
787
|
|
|
788
788
|
**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
789
|
`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
790
|
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
791
|
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 `"
|
|
792
|
+
on every call of /admin. `session(request)` gives `{ address, createdAt, via }` (a stored word other than `"wallet"`, `"nanoterra"` or `"passkey"` is `"unknown"`: refused;
|
|
793
|
+
a `"passkey"` session also carries `linkedTo`, only while its link exists: see below).
|
|
793
794
|
|
|
794
795
|
**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
796
|
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 +818,79 @@ check every 2 seconds, and `done()` once the owner has signed in. Options: `base
|
|
|
817
818
|
`session: adminSignin.session`, and in `admin.ts` `signIn: adminSignIn(...)`, `signOut: adminSignOut()`. The player's sign-in then needs no `sharedPaths`, and
|
|
818
819
|
`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
820
|
|
|
821
|
+
### A passkey for the owner (since 0.12.0)
|
|
822
|
+
|
|
823
|
+
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
|
|
824
|
+
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.
|
|
825
|
+
|
|
826
|
+
**The security rule, in plain words.** A link counts only when each side proves its key, as nano133.com's account links do:
|
|
827
|
+
|
|
828
|
+
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
|
|
829
|
+
session cannot add a passkey, and neither can a player's login, a Nanoterra sign-in or another wallet.
|
|
830
|
+
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>`.
|
|
831
|
+
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
|
|
832
|
+
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
|
|
833
|
+
**once** (the admin's own table) and within **5 minutes**.
|
|
834
|
+
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
|
|
835
|
+
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
|
|
836
|
+
/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
|
|
837
|
+
address, one day, the clock) is the same as for the wallet.
|
|
838
|
+
|
|
839
|
+
```ts
|
|
840
|
+
// The Worker: the same createAdminSignin, with passkeys on
|
|
841
|
+
const adminSignin = createAdminSignin({ store: createD1AdminSignin(env.DB), owner: receiveAddress(env), passkeys: true, limit, visitor, personCheck });
|
|
842
|
+
|
|
843
|
+
// The admin page: the Passkey tile in the sign-in, and the "Owner passkeys" card in Settings
|
|
844
|
+
import { mountAdmin } from "@nano133/nanoterra/admin/panel";
|
|
845
|
+
import { adminSignIn, adminSignOut, ownerPasskeys } from "@nano133/nanoterra/admin-signin/panel";
|
|
846
|
+
import { SCHEME } from "./scheme"; // the game's FROZEN passkey scheme: the same one the player sheet takes
|
|
847
|
+
mountAdmin(el, {
|
|
848
|
+
looks,
|
|
849
|
+
signIn: adminSignIn({ personCheck, passkeys: true, scheme: SCHEME, name: "My Game" }),
|
|
850
|
+
signOut: adminSignOut(),
|
|
851
|
+
cards: [ownerPasskeys({ scheme: SCHEME, name: "My Game" })],
|
|
852
|
+
});
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
| Route (with `passkeys: true`) | Who may call it | What it does |
|
|
856
|
+
|---|---|---|
|
|
857
|
+
| `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` |
|
|
858
|
+
| `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, ... }` |
|
|
859
|
+
| `POST {base}/passkeys` | any session the guard accepts | `{ passkeys: [{ address, createdAt }], limit, via, canChange, you }` (`canChange` only for the wallet) |
|
|
860
|
+
| `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 }` |
|
|
861
|
+
|
|
862
|
+
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
|
|
863
|
+
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
|
|
864
|
+
`reason: "wallet_only"`. "Sign out everywhere" ends the passkeys' sessions too, and keeps the links (Remove ends one).
|
|
865
|
+
|
|
866
|
+
**The panel.** `adminSignIn({ passkeys: true, scheme, name })` adds a **Passkey** tile after the wallets ("Use my passkey"). `ownerPasskeys({ scheme, name })` is
|
|
867
|
+
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
|
|
868
|
+
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
|
|
869
|
+
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
|
|
870
|
+
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.
|
|
871
|
+
|
|
872
|
+
**The store** needs the passkey methods (`createSession`, `linkPasskey`, `unlinkPasskey`, `passkeys`, `linked`, `useOnce`; the package's three stores have them, and
|
|
873
|
+
`createAdminSignin({ passkeys: true })` refuses a store without them); `adminSigninStoreConformance(make, { passkeys: true })` proves them. **D1** keeps them in two
|
|
874
|
+
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
|
|
875
|
+
`d1.sql` before 0.12.0 adds the same two statements:
|
|
876
|
+
|
|
877
|
+
```sql
|
|
878
|
+
CREATE TABLE IF NOT EXISTS admin_passkeys (
|
|
879
|
+
owner TEXT NOT NULL,
|
|
880
|
+
address TEXT NOT NULL,
|
|
881
|
+
created_at INTEGER NOT NULL,
|
|
882
|
+
PRIMARY KEY (owner, address)
|
|
883
|
+
);
|
|
884
|
+
CREATE TABLE IF NOT EXISTS admin_once (
|
|
885
|
+
key TEXT PRIMARY KEY,
|
|
886
|
+
until INTEGER NOT NULL
|
|
887
|
+
);
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
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
|
|
891
|
+
per owner in `admin_passkeys/{owner}` (`{ owner, passkeys: [{ address, createdAt }] }`, no `deleteAt`) and the used signatures in `admin_once/{key}` (`{ until,
|
|
892
|
+
deleteAt }`: add it to the TTL policy). Other names: `createD1AdminSignin(db, { passkeys, once })`, `createFirestoreAdminSignin(db, { passkeys, once })`.
|
|
893
|
+
|
|
820
894
|
## The friends list (`/friends`; since 0.6.0)
|
|
821
895
|
|
|
822
896
|
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 +1596,6 @@ counter** as they are, and the page needs no change for the server part.
|
|
|
1522
1596
|
- 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
1597
|
- Shared geometry for many characters at once, and a react-three-fiber wrapper.
|
|
1524
1598
|
- `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
1599
|
- A creator option to accept Berry Wallet only.
|
|
1527
1600
|
- 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
1601
|
|
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`.
|
|
279
|
-
6. **
|
|
280
|
-
the
|
|
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
|
|
package/dist/admin/guard.d.ts
CHANGED
|
@@ -2,8 +2,16 @@
|
|
|
2
2
|
export type AdminSession = {
|
|
3
3
|
address: string;
|
|
4
4
|
createdAt: number;
|
|
5
|
-
/**
|
|
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). */
|
package/dist/admin/guard.js
CHANGED
|
@@ -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
|
|
47
|
-
|
|
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;
|
|
@@ -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 = {
|
|
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
|
|
45
|
+
export type FirestoreAdminSigninCollections = {
|
|
46
46
|
requests?: string;
|
|
47
47
|
sessions?: string;
|
|
48
48
|
logins?: string;
|
|
49
|
-
|
|
49
|
+
passkeys?: string;
|
|
50
|
+
once?: string;
|
|
51
|
+
};
|
|
52
|
+
export declare function createFirestoreAdminSignin(db: FirestoreAdminSigninLike, collections?: FirestoreAdminSigninCollections): Required<AdminSigninStore>;
|
|
@@ -9,13 +9,16 @@
|
|
|
9
9
|
// signin_requests/{key} { id, way, starter, status, expiresAt, nonce?, issuedAt?, label?, account?, delivered, scope: "admin", deleteAt }
|
|
10
10
|
// sessions/{idHash} { address, createdAt, expiresAt, via, scope: "admin", deleteAt }
|
|
11
11
|
// admin_logins/{id} { at, country, browser, deleteAt }
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// admin_passkeys/{owner} { owner, passkeys: [{ address, createdAt }] } (since 0.12.0: the owner's linked passkeys, at most 10; no deleteAt)
|
|
13
|
+
// admin_once/{key} { until, deleteAt } (since 0.12.0: the passkeys' used signatures)
|
|
14
|
+
// Times are ms numbers; `deleteAt` is a Date (a Timestamp in Firestore): set a TTL policy on `deleteAt` for each collection that has it. `{key}` is the
|
|
15
|
+
// request's id; `{idHash}` the SHA-256 of "admin:" and the session's token. Every change that reads first is a transaction (the owner's passkeys are ONE
|
|
16
|
+
// document, so a link holds the limit in its transaction). Indexes: Firestore's own single-field indexes are enough (`id`, `scope`, `expiresAt`, `at`,
|
|
17
|
+
// `address`, `until`).
|
|
15
18
|
//
|
|
16
19
|
// The admin store for Firestore (`createFirestoreStore`) has no sign-in list and no "Sign out everywhere": give it this sign-in's,
|
|
17
20
|
// `store.recentLogins = signin.recentLogins` and `store.signOutEverywhere = signin.signOutEverywhere`.
|
|
18
|
-
import { ADMIN_LOGIN_KEEP_MS, ADMIN_WAYS } from "../types.js";
|
|
21
|
+
import { ADMIN_LOGIN_KEEP_MS, ADMIN_PASSKEY_LIMIT, ADMIN_WAYS } from "../types.js";
|
|
19
22
|
const DAY = 86_400_000;
|
|
20
23
|
/** At most this many documents go in one batch (Firestore's limit is 500). */
|
|
21
24
|
const BATCH = 400;
|
|
@@ -40,10 +43,21 @@ function requestOf(d, key, way) {
|
|
|
40
43
|
...(account !== undefined ? { account } : {}),
|
|
41
44
|
};
|
|
42
45
|
}
|
|
46
|
+
/** The links a passkeys document holds: only well-formed ones. */
|
|
47
|
+
function linksOf(d) {
|
|
48
|
+
const list = Array.isArray(d?.passkeys) ? d.passkeys : [];
|
|
49
|
+
return list.flatMap((p) => {
|
|
50
|
+
const v = p;
|
|
51
|
+
const address = text(v?.address);
|
|
52
|
+
return address !== undefined ? [{ address, createdAt: num(v?.createdAt) ?? 0 }] : [];
|
|
53
|
+
});
|
|
54
|
+
}
|
|
43
55
|
export function createFirestoreAdminSignin(db, collections = {}) {
|
|
44
56
|
const requestsCol = () => db.collection(collections.requests ?? "signin_requests");
|
|
45
57
|
const sessionsCol = () => db.collection(collections.sessions ?? "sessions");
|
|
46
58
|
const loginsCol = () => db.collection(collections.logins ?? "admin_logins");
|
|
59
|
+
const passkeysCol = () => db.collection(collections.passkeys ?? "admin_passkeys");
|
|
60
|
+
const onceCol = () => db.collection(collections.once ?? "admin_once");
|
|
47
61
|
const sessionData = (s) => ({ address: s.address, createdAt: s.createdAt, expiresAt: s.expiresAt, via: s.via, scope: "admin", deleteAt: new Date(s.expiresAt) });
|
|
48
62
|
const stores = new Map();
|
|
49
63
|
for (const way of ADMIN_WAYS) {
|
|
@@ -173,7 +187,62 @@ export function createFirestoreAdminSignin(db, collections = {}) {
|
|
|
173
187
|
const old = (await requestsCol().where("expiresAt", "<", now - 2 * 3_600_000).limit(BATCH).get()).docs.filter((d) => d.data().scope === "admin");
|
|
174
188
|
const ended = (await sessionsCol().where("expiresAt", "<", now).limit(BATCH).get()).docs.filter((d) => d.data().scope === "admin");
|
|
175
189
|
const logins = (await loginsCol().where("at", "<", now - ADMIN_LOGIN_KEEP_MS).limit(BATCH).get()).docs;
|
|
176
|
-
await
|
|
190
|
+
const used = (await onceCol().where("until", "<", now + 1).limit(BATCH).get()).docs;
|
|
191
|
+
await deleteAll([...old, ...ended, ...logins, ...used]);
|
|
192
|
+
},
|
|
193
|
+
async createSession(session) {
|
|
194
|
+
const ref = sessionsCol().doc(session.idHash);
|
|
195
|
+
await db.runTransaction(async (tx) => {
|
|
196
|
+
if ((await tx.get(ref)).exists)
|
|
197
|
+
throw new Error("a session with this id exists");
|
|
198
|
+
tx.set(ref, sessionData(session));
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
async linkPasskey(owner, address, at) {
|
|
202
|
+
const ref = passkeysCol().doc(owner);
|
|
203
|
+
return db.runTransaction(async (tx) => {
|
|
204
|
+
const d = (await tx.get(ref)).data();
|
|
205
|
+
const list = d && d.owner === owner ? linksOf(d) : [];
|
|
206
|
+
if (list.some((p) => p.address === address))
|
|
207
|
+
return "exists";
|
|
208
|
+
if (list.length >= ADMIN_PASSKEY_LIMIT)
|
|
209
|
+
return "full";
|
|
210
|
+
tx.set(ref, { owner, passkeys: [...list, { address, createdAt: at }] });
|
|
211
|
+
return "linked";
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
async unlinkPasskey(owner, address) {
|
|
215
|
+
const ref = passkeysCol().doc(owner);
|
|
216
|
+
const had = await db.runTransaction(async (tx) => {
|
|
217
|
+
const d = (await tx.get(ref)).data();
|
|
218
|
+
const list = d && d.owner === owner ? linksOf(d) : [];
|
|
219
|
+
if (!list.some((p) => p.address === address))
|
|
220
|
+
return false;
|
|
221
|
+
tx.set(ref, { owner, passkeys: list.filter((p) => p.address !== address) });
|
|
222
|
+
return true;
|
|
223
|
+
});
|
|
224
|
+
// The passkey's admin sessions end with its link (after it: a session read in between finds no link and is refused anyway).
|
|
225
|
+
await deleteAll((await sessionsCol().where("address", "==", address).get()).docs.filter((x) => x.data().scope === "admin" && x.data().via === "passkey"));
|
|
226
|
+
return had;
|
|
227
|
+
},
|
|
228
|
+
async passkeys(owner) {
|
|
229
|
+
const d = (await passkeysCol().doc(owner).get()).data();
|
|
230
|
+
return d && d.owner === owner ? linksOf(d) : [];
|
|
231
|
+
},
|
|
232
|
+
async linked(owner, address) {
|
|
233
|
+
const d = (await passkeysCol().doc(owner).get()).data();
|
|
234
|
+
return Boolean(d && d.owner === owner && linksOf(d).some((p) => p.address === address));
|
|
235
|
+
},
|
|
236
|
+
async useOnce(key, ttlMs) {
|
|
237
|
+
const ref = onceCol().doc(key);
|
|
238
|
+
return db.runTransaction(async (tx) => {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
const d = (await tx.get(ref)).data();
|
|
241
|
+
if (d && (num(d.until) ?? Infinity) > now)
|
|
242
|
+
return false;
|
|
243
|
+
tx.set(ref, { until: now + ttlMs, deleteAt: new Date(now + ttlMs) });
|
|
244
|
+
return true;
|
|
245
|
+
});
|
|
177
246
|
},
|
|
178
247
|
};
|
|
179
248
|
}
|
|
@@ -8,4 +8,11 @@ export type AdminSigninConformanceCase = {
|
|
|
8
8
|
name: string;
|
|
9
9
|
run: () => Promise<void>;
|
|
10
10
|
};
|
|
11
|
-
export
|
|
11
|
+
export type AdminSigninConformanceOptions = {
|
|
12
|
+
/**
|
|
13
|
+
* The owner's passkeys (the store's optional passkey methods). true: the cases run and the store must have them. false: no passkey case. Not set: the
|
|
14
|
+
* cases are listed, and a store with no `linkPasskey` passes them with nothing to check (a store written for 0.11.0).
|
|
15
|
+
*/
|
|
16
|
+
passkeys?: boolean;
|
|
17
|
+
};
|
|
18
|
+
export declare function adminSigninStoreConformance(make: () => Promise<AdminSigninHarness>, options?: AdminSigninConformanceOptions): AdminSigninConformanceCase[];
|