@nano133/nanoterra 0.10.1 → 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 +46 -0
- package/README.md +180 -10
- package/ai/SPEC.md +37 -2
- 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 +12 -0
- package/dist/admin-signin/adapters/d1.js +161 -0
- package/dist/admin-signin/adapters/firestore.d.ts +52 -0
- package/dist/admin-signin/adapters/firestore.js +248 -0
- package/dist/admin-signin/browser.d.ts +4 -0
- package/dist/admin-signin/browser.js +36 -0
- package/dist/admin-signin/conformance.d.ts +18 -0
- package/dist/admin-signin/conformance.js +275 -0
- package/dist/admin-signin/handler.d.ts +69 -0
- package/dist/admin-signin/handler.js +488 -0
- package/dist/admin-signin/index.d.ts +8 -0
- package/dist/admin-signin/index.js +16 -0
- package/dist/admin-signin/memory.d.ts +17 -0
- package/dist/admin-signin/memory.js +147 -0
- package/dist/admin-signin/panel/index.d.ts +6 -0
- package/dist/admin-signin/panel/index.js +7 -0
- 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 +52 -0
- package/dist/admin-signin/panel/view.js +229 -0
- package/dist/admin-signin/types.d.ts +155 -0
- package/dist/admin-signin/types.js +59 -0
- package/package.json +9 -2
- 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 +62 -0
- package/src/admin-signin/adapters/d1.ts +170 -0
- package/src/admin-signin/adapters/firestore.ts +274 -0
- package/src/admin-signin/browser.ts +38 -0
- package/src/admin-signin/conformance.ts +308 -0
- package/src/admin-signin/handler.ts +545 -0
- package/src/admin-signin/index.ts +34 -0
- package/src/admin-signin/memory.ts +141 -0
- package/src/admin-signin/panel/index.ts +11 -0
- package/src/admin-signin/panel/passkeys.ts +245 -0
- package/src/admin-signin/panel/view.ts +279 -0
- package/src/admin-signin/types.ts +146 -0
- package/src/player-signin/panel/style.css +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
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
|
+
|
|
29
|
+
## 0.11.0 (2026-11-01)
|
|
30
|
+
|
|
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
|
|
32
|
+
sign-in moved into the package with every check and every signed text unchanged: Berry Wallet signs the page `https://<host>/admin` (no query), Nautilus the
|
|
33
|
+
label "ADMIN of <host> sign-in ABCD-EFGH from <country>"; a request lives 2 minutes, is delivered one more minute and works once; only the browser that started it
|
|
34
|
+
and only the game's own address (`owner`; another wallet gets `not_owner`) make an admin login; the cookie `admin` (`__Host-admin` on https, one day) and the
|
|
35
|
+
database's `sha256("admin:" + token)` keep it apart from the player's login; each login writes the sign-in list (time, country, short browser name, 30 days).
|
|
36
|
+
`createAdminSignin({ store, owner, ... })` gives `handle`, `session` (for `createAdminHandler`), `signOut`, `signOutEverywhere`, `recentLogins` and `clean`.
|
|
37
|
+
- **NanChat is off for the admin** (`nanchat: false`, the default): its text must start "Login to <host>" and cannot name the ADMIN, so a start is refused with
|
|
38
|
+
"NanChat cannot open the admin yet. Use Berry Wallet or Nautilus." `nanchat: true` turns it on (its "Login to <host>" text, the same admin checks). Berry
|
|
39
|
+
Wallet is first in every wallet list.
|
|
40
|
+
- Stores: `createD1AdminSignin` on the starter's own tables (`signin_requests` and `sessions` with `scope = 'admin'`, `admin_logins`: **no migration** for the
|
|
41
|
+
starter; a new game adds `/admin-signin/d1.sql`), `createFirestoreAdminSignin`, `createMemoryAdminSignin`, and `adminSigninStoreConformance`
|
|
42
|
+
(`/admin-signin/conformance`) for a store a game writes itself.
|
|
43
|
+
- The panel `/admin-signin/panel`: `adminSignIn()` and `adminSignOut()` fit `mountAdmin`'s `signIn` and `signOut`; Berry Wallet and Nautilus as tiles (NanChat with
|
|
44
|
+
`nanchat: true`), one key on a phone, a QR code on a computer (the package's own; Nautilus's link is too long for it and shows as a link). Its stylesheet
|
|
45
|
+
`/admin-signin/style.css` is the player sheet's (which gets a two-tile row).
|
|
46
|
+
- The package's tests run `adminSigninConformance` (the player's sign-in and the admin's side by side, Berry Wallet, Nautilus and NanChat turned on) and
|
|
47
|
+
`adminConformance` (with `createAdminHandler` and the sign-in's `session`) on the memory store and on D1.
|
|
48
|
+
|
|
3
49
|
## 0.10.1 (2026-11-01)
|
|
4
50
|
|
|
5
51
|
- **The admin conformance suite's heavy checks are one case for each route.** "Other spellings of EVERY admin path" and "a request from another
|
package/README.md
CHANGED
|
@@ -74,6 +74,10 @@ 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, 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
|
+
| `@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 |
|
|
77
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 |
|
|
78
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 |
|
|
79
83
|
| `@nano133/nanoterra/friends/panel` | the Friends key and panel: the nano133 window, or the game's own list, and its stylesheet `@nano133/nanoterra/friends/style.css` (since 0.6.0) | no | 5.0 kB + 4.8 kB of CSS (minified) |
|
|
@@ -580,8 +584,8 @@ when your store has the call and your host can move its clock: a host that then
|
|
|
580
584
|
|
|
581
585
|
**List the other routes that your own router serves under the admin base** in `host.otherRoutes` (`{ method, path }`: an old `/api/admin/orders`, a debug route, a route kept for an old page).
|
|
582
586
|
The suite sends each one as every visitor who is not the owner and requires a refusal. It cannot find a route that your router hides from it, so a route that answers BEFORE the handler with no
|
|
583
|
-
guard is a fault only if you list it. **What the suite cannot see:** the code that CREATES admin logins (your wallet sign-in). Test that separately
|
|
584
|
-
admin may make an admin login, and never from another sign-in. A security read found these gaps in the first version of this suite, and each one is a test now.
|
|
587
|
+
guard is a fault only if you list it. **What the suite cannot see:** the code that CREATES admin logins (your wallet sign-in). Test that separately (the sign-in chain contract above;
|
|
588
|
+
the package's own sign-in, `/admin-signin`, passes it): only a wallet signature started from the admin may make an admin login, and never from another sign-in. A security read found these gaps in the first version of this suite, and each one is a test now.
|
|
585
589
|
|
|
586
590
|
### The store (an adapter for your database)
|
|
587
591
|
|
|
@@ -618,16 +622,16 @@ With D1: `createD1Store(db, { logins: true })` needs the table `admin_logins (id
|
|
|
618
622
|
`sessions` (`'admin'` for an admin login) and a `scope` column and a `delivered` column on `signin_requests` ("Sign out everywhere" also marks every admin request that was started and not delivered as
|
|
619
623
|
delivered and ends it (`expires_at = 0`, so the wallet app does not show "Signed in" for it either), so a request that the owner signed a moment ago, or signs a moment later, cannot make a new admin login afterwards). The panel shows a browser name only when it is words (letters and spaces, 40 at most): anything else
|
|
620
624
|
a store gives, such as a raw browser text or an address, is shown as "Unknown browser". Without the option the store has neither call, so a game without those tables is not broken. The store contract has cases for both calls.
|
|
621
|
-
The Firestore store does not have them yet (a game's sessions are its own): a game with Firestore
|
|
625
|
+
The Firestore store does not have them yet (a game's sessions are its own): a game with Firestore gives it the admin sign-in's (`store.recentLogins = signin.recentLogins;
|
|
626
|
+
store.signOutEverywhere = signin.signOutEverywhere`, see "The admin's sign-in"), or writes its own.
|
|
622
627
|
|
|
623
628
|
### Later ideas (not built)
|
|
624
629
|
|
|
625
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
|
|
626
631
|
little gain. A later version could ask for it if the admin ever writes more.
|
|
627
|
-
- **An optional passkey for the admin, bound to the domain.**
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
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.
|
|
631
635
|
|
|
632
636
|
### The panel
|
|
633
637
|
|
|
@@ -653,7 +657,8 @@ real tabs (the arrow keys, Home and End work); every tab is made once, so a draf
|
|
|
653
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
|
|
654
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`,
|
|
655
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
|
|
656
|
-
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`.
|
|
657
662
|
|
|
658
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¢ ·
|
|
659
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
|
|
@@ -690,7 +695,8 @@ element that holds it. The keys, inputs, switches, swatches and price steps are
|
|
|
690
695
|
width (a container query), not the window's: on a phone two cards a row and the five tabs as icons over their words; three cards a row and the orders as a table from
|
|
691
696
|
760 px; four cards from 1000 px. The panel can be up to 1200 px wide: the page that holds it sets the width (the starter's admin page is 900 px).
|
|
692
697
|
|
|
693
|
-
The
|
|
698
|
+
The panel never signs anyone in: `signIn` draws the sign-in. Since 0.11.0 the package has one, `adminSignIn()` from `@nano133/nanoterra/admin-signin/panel` (see "The admin's
|
|
699
|
+
sign-in"); a game with its own keeps giving its own.
|
|
694
700
|
|
|
695
701
|
**Light and dark** (since 0.7.0). The top bar has a theme key (44 px, a half circle, a sun or a moon; its name says the choice: "Theme: Dark", "Theme: Device (dark
|
|
696
702
|
now)"). A press opens a menu of three: **Device** (the default: it follows the device's setting, `prefers-color-scheme`, and a live change of it), **Light** and **Dark**.
|
|
@@ -723,6 +729,168 @@ html[data-n133-theme="dark"] body.admin-page {
|
|
|
723
729
|
The pictures: a look's flat picture and the character previews (`createLookPreview` draws with a clear background) stand on the `stage` colour, which is a mid grey
|
|
724
730
|
blue in dark, so a dark outfit and the outlines still show. The panel draws no QR code.
|
|
725
731
|
|
|
732
|
+
## The admin's sign-in (`/admin-signin`; since 0.11.0)
|
|
733
|
+
|
|
734
|
+
The wallet sign-in that makes the ADMIN login, in the package, on **one fixed route in every game: `/api/admin-signin`** (`ADMIN_SIGNIN_BASE`). Before it, every game put it
|
|
735
|
+
somewhere else (the game starter: `/api/signin` with `scope: "admin"` in the body; AI helpers: their own `/api/admin-signin`). It is the game starter's admin sign-in moved into
|
|
736
|
+
the package, **with every check and every signed text unchanged**; only the paths are new. The checks are `@nano133/signin`'s (install it with this package).
|
|
737
|
+
|
|
738
|
+
```ts
|
|
739
|
+
// The Worker (D1)
|
|
740
|
+
import { createAdminSignin } from "@nano133/nanoterra/admin-signin";
|
|
741
|
+
import { createD1AdminSignin } from "@nano133/nanoterra/admin-signin/d1";
|
|
742
|
+
import { createAdminHandler } from "@nano133/nanoterra/admin";
|
|
743
|
+
import { createD1Store } from "@nano133/nanoterra/admin/d1";
|
|
744
|
+
|
|
745
|
+
const adminSignin = createAdminSignin({
|
|
746
|
+
store: createD1AdminSignin(env.DB), // the starter's own tables: no migration (a new game adds "@nano133/nanoterra/admin-signin/d1.sql")
|
|
747
|
+
owner: receiveAddress(env), // the game's own address, or null: ONLY it makes an admin login
|
|
748
|
+
limit: (key, n, ms, now) => d1Limit(env.DB, key, n, ms, now), // optional: your limiter for the starts (default: this copy's memory)
|
|
749
|
+
visitor: (req) => visitorKey(env.DB, req), // optional: a keyed hash of the IP address
|
|
750
|
+
personCheck: (token, req) => turnstileCheck(env, token, req.headers.get("CF-Connecting-IP")), // optional (Turnstile)
|
|
751
|
+
});
|
|
752
|
+
const admin = createAdminHandler({
|
|
753
|
+
store: createD1Store(env.DB, { logins: true }), // the Sign-ins card and "Sign out everywhere", on the same tables
|
|
754
|
+
defaults, owner: receiveAddress(env),
|
|
755
|
+
session: (req, now) => adminSignin.session(req, now), // the ADMIN cookie only
|
|
756
|
+
status, lookName, check,
|
|
757
|
+
});
|
|
758
|
+
const answer = (await adminSignin.handle(request)) ?? (await admin.handle(request)); // null: not theirs; the game goes on
|
|
759
|
+
// the cron: await adminSignin.clean();
|
|
760
|
+
|
|
761
|
+
// The admin page
|
|
762
|
+
import { mountAdmin } from "@nano133/nanoterra/admin/panel";
|
|
763
|
+
import { adminSignIn, adminSignOut } from "@nano133/nanoterra/admin-signin/panel";
|
|
764
|
+
import "@nano133/nanoterra/admin/style.css";
|
|
765
|
+
import "@nano133/nanoterra/admin-signin/style.css";
|
|
766
|
+
mountAdmin(el, { looks, signIn: adminSignIn({ personCheck }), signOut: adminSignOut() });
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
| Route (`base` "/api/admin-signin") | Who calls it | What it does |
|
|
770
|
+
|---|---|---|
|
|
771
|
+
| `POST {base}/start` `{ way, turnstile? }` | the admin page | starts an admin request (`way` "berry", the default, or "nautilus"): `{ way, id, link, walletLink?, code?, expiresAt }` and the starter cookie `starter-admin` (30 minutes) |
|
|
772
|
+
| `GET`, `POST {base}/nano/:id` | Berry Wallet | reads the request; posts its signature (204 and no body when accepted, 401 and the words when refused) |
|
|
773
|
+
| `POST {base}/nautilus?id=` | Nautilus | its signature (always 200: Nautilus shows only a 200's words) |
|
|
774
|
+
| `POST {base}/nanchat` | NanChat's relay server | only with `nanchat: true` (else no route): its signature (always 200; a refusal starts "NOT signed in") |
|
|
775
|
+
| `POST {base}/check` `{ id, way }` | the admin page, every 2 s | `{ status }`: `waiting`, `expired`, `elsewhere`, `used`, `not_owner` (with `address`: another wallet signed; no login, the request stays unused), or `signedin` with `address` and the admin cookie |
|
|
776
|
+
| `POST {base}/signout` | the admin page | ends this browser's ADMIN login only: `{ ok: true }` |
|
|
777
|
+
|
|
778
|
+
**What the wallet signs names the ADMIN**, so a link that a stranger sends to the owner does not look like any sign-in. Berry Wallet signs the page `https://<host>/admin`
|
|
779
|
+
(`page`, with **no query**: Berry refused `/admin?from=US`); Nautilus signs the label `ADMIN of <host> sign-in ABCD-EFGH from <country>` and shows "Sign in to the ADMIN of
|
|
780
|
+
<host>. Started <time> UTC from <country>. If you did not start this, do not sign." It names the owner's address (no key, no payment). An admin request **lives 2
|
|
781
|
+
minutes**, is delivered for **one more minute**, and works **once**; only the browser that started it gets the login.
|
|
782
|
+
|
|
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
|
+
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
|
+
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. A passkey opens /admin only when the owner linked it (`passkeys: true`, see "A passkey for the owner" below).
|
|
787
|
+
|
|
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
|
+
`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
|
+
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
|
+
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"`, `"nanoterra"` or `"passkey"` is `"unknown"`: refused;
|
|
793
|
+
a `"passkey"` session also carries `linkedTo`, only while its link exists: see below).
|
|
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
|
|
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
|
|
797
|
+
request that was not delivered; `recentLogins(limit)` reads the list (give both to an admin store that has neither: the Firestore one, or the memory one in a test).
|
|
798
|
+
|
|
799
|
+
**The stores** read and write only their three tables or collections, and only the admin's rows:
|
|
800
|
+
|
|
801
|
+
- **D1:** `createD1AdminSignin(env.DB)` works on the game starter's own tables and columns: `signin_requests` (`scope = 'admin'`), `sessions` (`scope = 'admin'`)
|
|
802
|
+
and `admin_logins (id, at, country, browser)`. **The starter needs no migration**, and every creator who is signed in stays signed in. A new game adds
|
|
803
|
+
`@nano133/nanoterra/admin-signin/d1.sql` to its migrations (the same tables, `CREATE TABLE IF NOT EXISTS`; beside the player sign-in's d1.sql it adds only `admin_logins`).
|
|
804
|
+
"Sign out everywhere" is the same two statements as `createD1Store(db, { logins: true })`. Other table names: `createD1AdminSignin(db, { requests, sessions, logins })`.
|
|
805
|
+
- **Firestore:** `createFirestoreAdminSignin(db, { requests, sessions, logins })` (default `signin_requests`, `sessions`, `admin_logins`; the player sign-in's
|
|
806
|
+
adapter keeps its own documents in the first two with `scope: "play"`). Each document carries `deleteAt`: set a TTL policy on it.
|
|
807
|
+
- **Your own database:** an `AdminSigninStore` that passes `adminSigninStoreConformance` from `@nano133/nanoterra/admin-signin/conformance`. The whole chain (the
|
|
808
|
+
player's sign-in and the admin's, side by side) passes `adminSigninConformance` from `@nano133/nanoterra/admin/conformance`: the package runs it on the memory store and on D1,
|
|
809
|
+
with Berry Wallet, Nautilus and NanChat turned on.
|
|
810
|
+
|
|
811
|
+
**The rest:** `base` (keep the default), `page` (default `/admin`), `domain` (the host every text names; a call to another host is refused), `cookies`
|
|
812
|
+
(default `{ session: "admin", starter: "starter-admin" }`), `now`, `onError`. Starts: 20 in 10 minutes per visitor (the player's key: one count for both);
|
|
813
|
+
checks 90 a minute and the wallets' calls 30 a minute per IP address. **The panel** (`adminSignIn(options)`, `adminSigninView(options)`, `adminSignOut(options)`): the
|
|
814
|
+
wallets as tiles, on a phone one key that opens the app, on a computer a QR code (the package's own; a link that is too long for it, Nautilus's, shows as a link), the
|
|
815
|
+
check every 2 seconds, and `done()` once the owner has signed in. Options: `base`, `nanchat`, `personCheck(box)`, `qr(text)`, `touch`, `fetch`. Text 16 px, keys 48 px.
|
|
816
|
+
|
|
817
|
+
**Moving the game starter onto it:** use `createD1AdminSignin(env.DB)` (no migration), answer `adminSignin.handle(request)` before the player's sign-in, give the admin handler
|
|
818
|
+
`session: adminSignin.session`, and in `admin.ts` `signIn: adminSignIn(...)`, `signOut: adminSignOut()`. The player's sign-in then needs no `sharedPaths`, and
|
|
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.
|
|
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
|
+
|
|
726
894
|
## The friends list (`/friends`; since 0.6.0)
|
|
727
895
|
|
|
728
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):
|
|
@@ -1127,7 +1295,8 @@ These are the game starter's own routes and answers, so its page works unchanged
|
|
|
1127
1295
|
exchange and returns the Set-Cookie value; `signin.signOut(request)` likewise; `signin.forget(player)` is for **"Delete my data"** (every session, signed
|
|
1128
1296
|
request and once-only row of the player); `signin.clean()` is for a cron.
|
|
1129
1297
|
- **`sharedPaths: true`** for a game whose own admin signs in on the same paths (the starter's `scope: "admin"`): the admin's calls, and a wallet's call for a
|
|
1130
|
-
request this store does not hold, answer null, and the game's admin code answers them.
|
|
1298
|
+
request this store does not hold, answer null, and the game's admin code answers them. A game that uses the package's admin sign-in (`/admin-signin`, on its own
|
|
1299
|
+
route) does not need it.
|
|
1131
1300
|
- `ways` (default all three), `passkey: false`, `base`, `signOutPath`, `cookies`, `sessionDays` change the rest.
|
|
1132
1301
|
|
|
1133
1302
|
**The stores** read and write only their three tables or collections, and only the player's rows:
|
|
@@ -1427,6 +1596,7 @@ counter** as they are, and the page needs no change for the server part.
|
|
|
1427
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).
|
|
1428
1597
|
- Shared geometry for many characters at once, and a react-three-fiber wrapper.
|
|
1429
1598
|
- `WebGPURenderer`: the ink is a GLSL `ShaderMaterial`, so use `WebGLRenderer`.
|
|
1599
|
+
- A creator option to accept Berry Wallet only.
|
|
1430
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.
|
|
1431
1601
|
|
|
1432
1602
|
## Build and test
|
package/ai/SPEC.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Package: `@nano133/nanoterra` (0.1.0 or later). Follow these steps in order. Each step ends with a check. Ask the owner when a step says
|
|
4
4
|
"ask". The package has five parts: the sign-in ticket (`/ticket`), the player's character (`/character`, Three.js), the
|
|
5
5
|
looks ladder (`/looks`), held input that does not stick (`/input`), the friends list (`/friends`, with its panel), the options menu (`/options`), the emotes (`/emotes`, with the wheel), the player's sign-in (`/player-signin`: a passkey or a wallet app, with
|
|
6
|
-
`@nano133/signin`) and the shop (`/shop`, with its panel). Use only the parts the game needs. Only the
|
|
6
|
+
`@nano133/signin`), the admin's sign-in (`/admin-signin`, with `@nano133/signin`) and the shop (`/shop`, with its panel). Use only the parts the game needs. Only the two sign-ins and the shop have Nano code; the shop holds no key and sends nothing: it reads the ledger. Read the package README first.
|
|
7
7
|
|
|
8
8
|
## 0. What the owner decides (ask)
|
|
9
9
|
|
|
@@ -255,7 +255,42 @@ import "@nano133/nanoterra/shop/style.css";
|
|
|
255
255
|
address and `TEST_MODE` off, the pay sheet shows the QR code and the exact amount; a second player gets the next step (0.0001 XNO more); another player's
|
|
256
256
|
order id answers 404; a payment of one raw too little is not matched. Test with a small real payment only when the owner says so, to the owner's own address.
|
|
257
257
|
|
|
258
|
-
## 14.
|
|
258
|
+
## 14. The admin's sign-in (a game with /admin)
|
|
259
|
+
|
|
260
|
+
```sh
|
|
261
|
+
npm install @nano133/nanoterra @nano133/signin
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
The admin's sign-in is the package's, on ONE fixed route in every game: `/api/admin-signin`. Do not write your own admin sign-in, do not put it on another path, and
|
|
265
|
+
do not change what the wallet signs.
|
|
266
|
+
|
|
267
|
+
1. **The store.** D1: `createD1AdminSignin(env.DB)` from `@nano133/nanoterra/admin-signin/d1` (a game made from the starter has the tables already: no migration; a
|
|
268
|
+
new game adds `@nano133/nanoterra/admin-signin/d1.sql` to its migrations). Firestore: `createFirestoreAdminSignin(db)` with a TTL policy on `deleteAt`. Another
|
|
269
|
+
database: a store that passes `adminSigninStoreConformance`.
|
|
270
|
+
2. **The server.** `const adminSignin = createAdminSignin({ store, owner, limit, visitor, personCheck })`, where `owner` is the creator's own address (RECEIVE_ADDRESS) or
|
|
271
|
+
null. Answer `adminSignin.handle(request)` under `/api/`, and give the admin handler `session: (req, now) => adminSignin.session(req, now)`. Only the owner's wallet
|
|
272
|
+
makes an admin login; a player's login never does. A cron calls `adminSignin.clean()`.
|
|
273
|
+
3. **"Sign out everywhere" and the Sign-ins card.** D1: `createD1Store(env.DB, { logins: true })` for the admin handler (the same tables). Another store: give it
|
|
274
|
+
`recentLogins: adminSignin.recentLogins` and `signOutEverywhere: adminSignin.signOutEverywhere`.
|
|
275
|
+
4. **The page.** `mountAdmin(el, { looks, signIn: adminSignIn({ personCheck }), signOut: adminSignOut() })` from `@nano133/nanoterra/admin-signin/panel`, with
|
|
276
|
+
`@nano133/nanoterra/admin-signin/style.css`.
|
|
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. **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.
|
|
287
|
+
|
|
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
|
|
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
|
|
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.
|
|
292
|
+
|
|
293
|
+
## 15. Do not
|
|
259
294
|
|
|
260
295
|
- Do not edit the geometry, the skeleton or the toon and ink materials.
|
|
261
296
|
- Do not add the katana, the headband or other brands' art. A game's own worn model goes on a socket (`c.socket("head" | "hand" | "back")` or
|
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
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { D1Like } from "../../admin/adapters/d1.js";
|
|
2
|
+
import { type AdminSigninStore } from "../types.js";
|
|
3
|
+
export type { D1Like, D1StatementLike } from "../../admin/adapters/d1.js";
|
|
4
|
+
/** The tables' names. Default: the game starter's. A game with other names changes d1.sql to match. */
|
|
5
|
+
export type D1AdminSigninTables = {
|
|
6
|
+
requests?: string;
|
|
7
|
+
sessions?: string;
|
|
8
|
+
logins?: string;
|
|
9
|
+
passkeys?: string;
|
|
10
|
+
once?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function createD1AdminSignin(db: D1Like, tables?: D1AdminSigninTables): Required<AdminSigninStore>;
|