@nano133/nanoterra 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +102 -5
  3. package/ai/SPEC.md +30 -2
  4. package/dist/admin-signin/adapters/d1.d.ts +10 -0
  5. package/dist/admin-signin/adapters/d1.js +106 -0
  6. package/dist/admin-signin/adapters/firestore.d.ts +49 -0
  7. package/dist/admin-signin/adapters/firestore.js +179 -0
  8. package/dist/admin-signin/browser.d.ts +4 -0
  9. package/dist/admin-signin/browser.js +36 -0
  10. package/dist/admin-signin/conformance.d.ts +11 -0
  11. package/dist/admin-signin/conformance.js +190 -0
  12. package/dist/admin-signin/handler.d.ts +62 -0
  13. package/dist/admin-signin/handler.js +360 -0
  14. package/dist/admin-signin/index.d.ts +8 -0
  15. package/dist/admin-signin/index.js +15 -0
  16. package/dist/admin-signin/memory.d.ts +13 -0
  17. package/dist/admin-signin/memory.js +101 -0
  18. package/dist/admin-signin/panel/index.d.ts +3 -0
  19. package/dist/admin-signin/panel/index.js +5 -0
  20. package/dist/admin-signin/panel/view.d.ts +40 -0
  21. package/dist/admin-signin/panel/view.js +182 -0
  22. package/dist/admin-signin/types.d.ts +105 -0
  23. package/dist/admin-signin/types.js +42 -0
  24. package/package.json +9 -2
  25. package/src/admin-signin/adapters/d1.sql +45 -0
  26. package/src/admin-signin/adapters/d1.ts +117 -0
  27. package/src/admin-signin/adapters/firestore.ts +207 -0
  28. package/src/admin-signin/browser.ts +38 -0
  29. package/src/admin-signin/conformance.ts +212 -0
  30. package/src/admin-signin/handler.ts +418 -0
  31. package/src/admin-signin/index.ts +29 -0
  32. package/src/admin-signin/memory.ts +100 -0
  33. package/src/admin-signin/panel/index.ts +7 -0
  34. package/src/admin-signin/panel/view.ts +217 -0
  35. package/src/admin-signin/types.ts +95 -0
  36. package/src/player-signin/panel/style.css +7 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 (2026-11-01)
4
+
5
+ - **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
6
+ 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
7
+ 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
8
+ 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
9
+ 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).
10
+ `createAdminSignin({ store, owner, ... })` gives `handle`, `session` (for `createAdminHandler`), `signOut`, `signOutEverywhere`, `recentLogins` and `clean`.
11
+ - **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
12
+ "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
13
+ Wallet is first in every wallet list.
14
+ - Stores: `createD1AdminSignin` on the starter's own tables (`signin_requests` and `sessions` with `scope = 'admin'`, `admin_logins`: **no migration** for the
15
+ starter; a new game adds `/admin-signin/d1.sql`), `createFirestoreAdminSignin`, `createMemoryAdminSignin`, and `adminSigninStoreConformance`
16
+ (`/admin-signin/conformance`) for a store a game writes itself.
17
+ - The panel `/admin-signin/panel`: `adminSignIn()` and `adminSignOut()` fit `mountAdmin`'s `signIn` and `signOut`; Berry Wallet and Nautilus as tiles (NanChat with
18
+ `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
19
+ `/admin-signin/style.css` is the player sheet's (which gets a two-tile row).
20
+ - 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
21
+ `adminConformance` (with `createAdminHandler` and the sign-in's `session`) on the memory store and on D1.
22
+
3
23
  ## 0.10.1 (2026-11-01)
4
24
 
5
25
  - **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 (server code; it needs `@nano133/signin`; since 0.11.0) | no | 5.5 kB without `@nano133/signin` |
78
+ | `@nano133/nanoterra/admin-signin/d1`, `/admin-signin/firestore` | its store on D1 (the starter's own tables; a new game adds `/admin-signin/d1.sql`) or Firestore (a small interface, no Firebase dependency) (since 0.11.0) | no | 1.4 kB, 1.7 kB |
79
+ | `@nano133/nanoterra/admin-signin/panel` | the admin's sign-in in the browser, for `mountAdmin`'s `signIn` and `signOut`: `adminSignIn()`, `adminSignOut()`, with its own QR code; its stylesheet `@nano133/nanoterra/admin-signin/style.css` is the player sheet's (since 0.11.0) | no | 6.8 kB |
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: only a wallet signature started from the
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,7 +622,8 @@ 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 writes its own `recentLogins` and `signOutEverywhere`.
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
 
@@ -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 wallet sign-in stays yours (`signIn`), because only your game can start a request in the ADMIN scope with its own cookie. The panel never signs anyone in.
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,94 @@ 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. **No passkey opens /admin** yet.
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"` or `"nanoterra"` is `"unknown"`: refused).
793
+
794
+ **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
+ 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
796
+ 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).
797
+
798
+ **The stores** read and write only their three tables or collections, and only the admin's rows:
799
+
800
+ - **D1:** `createD1AdminSignin(env.DB)` works on the game starter's own tables and columns: `signin_requests` (`scope = 'admin'`), `sessions` (`scope = 'admin'`)
801
+ 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
802
+ `@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`).
803
+ "Sign out everywhere" is the same two statements as `createD1Store(db, { logins: true })`. Other table names: `createD1AdminSignin(db, { requests, sessions, logins })`.
804
+ - **Firestore:** `createFirestoreAdminSignin(db, { requests, sessions, logins })` (default `signin_requests`, `sessions`, `admin_logins`; the player sign-in's
805
+ adapter keeps its own documents in the first two with `scope: "play"`). Each document carries `deleteAt`: set a TTL policy on it.
806
+ - **Your own database:** an `AdminSigninStore` that passes `adminSigninStoreConformance` from `@nano133/nanoterra/admin-signin/conformance`. The whole chain (the
807
+ 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,
808
+ with Berry Wallet, Nautilus and NanChat turned on.
809
+
810
+ **The rest:** `base` (keep the default), `page` (default `/admin`), `domain` (the host every text names; a call to another host is refused), `cookies`
811
+ (default `{ session: "admin", starter: "starter-admin" }`), `now`, `onError`. Starts: 20 in 10 minutes per visitor (the player's key: one count for both);
812
+ checks 90 a minute and the wallets' calls 30 a minute per IP address. **The panel** (`adminSignIn(options)`, `adminSigninView(options)`, `adminSignOut(options)`): the
813
+ 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
814
+ 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.
815
+
816
+ **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
817
+ `session: adminSignin.session`, and in `admin.ts` `signIn: adminSignIn(...)`, `signOut: adminSignOut()`. The player's sign-in then needs no `sharedPaths`, and
818
+ `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
+
726
820
  ## The friends list (`/friends`; since 0.6.0)
727
821
 
728
822
  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 +1221,8 @@ These are the game starter's own routes and answers, so its page works unchanged
1127
1221
  exchange and returns the Set-Cookie value; `signin.signOut(request)` likewise; `signin.forget(player)` is for **"Delete my data"** (every session, signed
1128
1222
  request and once-only row of the player); `signin.clean()` is for a cron.
1129
1223
  - **`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.
1224
+ 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
1225
+ route) does not need it.
1131
1226
  - `ways` (default all three), `passkey: false`, `base`, `signOutPath`, `cookies`, `sessionDays` change the rest.
1132
1227
 
1133
1228
  **The stores** read and write only their three tables or collections, and only the player's rows:
@@ -1427,6 +1522,8 @@ counter** as they are, and the page needs no change for the server part.
1427
1522
  - 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
1523
  - Shared geometry for many characters at once, and a react-three-fiber wrapper.
1429
1524
  - `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
+ - A creator option to accept Berry Wallet only.
1430
1527
  - 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
1528
 
1432
1529
  ## 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 player's sign-in and the shop have Nano code; the shop holds no key and sends nothing: it reads the ledger. Read the package README first.
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,35 @@ 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. Do not
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`. No passkey opens /admin yet.
279
+ 6. **Tests.** Run `adminSigninConformance` (`@nano133/nanoterra/admin/conformance`) on the game's own chain (its player sign-in and the admin's), and `adminConformance` with
280
+ the admin handler and `adminSignin.session`.
281
+
282
+ **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
+ 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."
285
+
286
+ ## 15. Do not
259
287
 
260
288
  - Do not edit the geometry, the skeleton or the toon and ink materials.
261
289
  - 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
@@ -0,0 +1,10 @@
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
+ };
10
+ export declare function createD1AdminSignin(db: D1Like, tables?: D1AdminSigninTables): AdminSigninStore;
@@ -0,0 +1,106 @@
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
+ // 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`).
4
+ //
5
+ // import { createD1AdminSignin } from "@nano133/nanoterra/admin-signin/d1";
6
+ // const store = createD1AdminSignin(env.DB);
7
+ //
8
+ // It reads and writes ONLY the admin's rows (scope = 'admin'): a game whose players sign in through the same tables (the player sign-in's scope 'play')
9
+ // 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
+ // 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
+ // "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
+ const toRequest = (r) => ({
14
+ id: r.rid ?? r.id,
15
+ starter: r.starter,
16
+ status: r.status === "signedin" ? "signedin" : "waiting",
17
+ expiresAt: Number(r.expires_at),
18
+ ...(r.label ? { label: r.label } : {}),
19
+ ...(r.nonce ? { nonce: r.nonce } : {}),
20
+ ...(r.issued_at ? { issuedAt: Number(r.issued_at) } : {}),
21
+ ...(r.account ? { account: r.account } : {}),
22
+ });
23
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
24
+ const changes = (res) => Number(res?.meta?.changes ?? 0);
25
+ export function createD1AdminSignin(db, tables = {}) {
26
+ const T = { requests: tables.requests ?? "signin_requests", sessions: tables.sessions ?? "sessions", logins: tables.logins ?? "admin_logins" };
27
+ for (const n of Object.values(T))
28
+ if (!NAME.test(n))
29
+ throw new Error(`not a table name: ${n}`);
30
+ const COLS = "id, rid, starter, status, expires_at, nonce, issued_at, label, account";
31
+ const stores = new Map();
32
+ for (const way of ADMIN_WAYS) {
33
+ stores.set(way, {
34
+ async create(key, r) {
35
+ // The starter's own insert: `scope` is written when the request is created, so a request started for the admin is the admin's for good.
36
+ await db
37
+ .prepare(`INSERT INTO ${T.requests} (id, rid, way, starter, status, expires_at, nonce, issued_at, label, scope) VALUES (?, ?, ?, ?, 'waiting', ?, ?, ?, ?, 'admin')`)
38
+ .bind(key, r.id, way, r.starter, r.expiresAt, r.nonce ?? null, r.issuedAt ?? null, r.label ?? null)
39
+ .run();
40
+ },
41
+ async get(key) {
42
+ const r = await db.prepare(`SELECT ${COLS} FROM ${T.requests} WHERE id = ? AND way = ? AND scope = 'admin'`).bind(key, way).first();
43
+ return r ? toRequest(r) : null;
44
+ },
45
+ async find(id) {
46
+ const r = await db.prepare(`SELECT ${COLS} FROM ${T.requests} WHERE rid = ? AND way = ? AND scope = 'admin'`).bind(id, way).first();
47
+ return r ? { key: r.id, request: toRequest(r) } : null;
48
+ },
49
+ async finish(key, account) {
50
+ return changes(await db.prepare(`UPDATE ${T.requests} SET status = 'signedin', account = ? WHERE id = ? AND way = ? AND status = 'waiting' AND scope = 'admin'`).bind(account, key, way).run()) === 1;
51
+ },
52
+ });
53
+ }
54
+ return {
55
+ requests: (way) => {
56
+ const s = stores.get(way);
57
+ if (!s)
58
+ throw new Error(`no such way: ${String(way)}`);
59
+ return s;
60
+ },
61
+ async deliver(way, key, s) {
62
+ // The starter's batch: the delivery, and the session only when the delivery changed a row.
63
+ const done = await db.batch([
64
+ db.prepare(`UPDATE ${T.requests} SET delivered = 1 WHERE id = ? AND way = ? AND delivered = 0 AND status = 'signedin' AND scope = 'admin'`).bind(key, way),
65
+ db.prepare(`INSERT INTO ${T.sessions} (id_hash, address, created_at, expires_at, via, scope) SELECT ?, ?, ?, ?, ?, 'admin' WHERE changes() = 1`).bind(s.idHash, s.address, s.createdAt, s.expiresAt, s.via),
66
+ ]);
67
+ return changes(done[0]) === 1;
68
+ },
69
+ async session(idHash, now) {
70
+ const r = await db
71
+ .prepare(`SELECT id_hash, address, created_at, expires_at, via FROM ${T.sessions} WHERE id_hash = ? AND expires_at > ? AND scope = 'admin'`)
72
+ .bind(idHash, now)
73
+ .first();
74
+ return r ? { idHash: r.id_hash, address: r.address, createdAt: Number(r.created_at), expiresAt: Number(r.expires_at), via: String(r.via) } : null;
75
+ },
76
+ async endSession(idHash) {
77
+ await db.prepare(`DELETE FROM ${T.sessions} WHERE id_hash = ? AND scope = 'admin'`).bind(idHash).run();
78
+ },
79
+ async signOutEverywhere() {
80
+ const done = await db.batch([
81
+ db.prepare(`DELETE FROM ${T.sessions} WHERE scope = 'admin'`),
82
+ db.prepare(`UPDATE ${T.requests} SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0`),
83
+ ]);
84
+ return changes(done[0]);
85
+ },
86
+ async recordLogin(login) {
87
+ await db.batch([
88
+ db.prepare(`INSERT INTO ${T.logins} (at, country, browser) VALUES (?, ?, ?)`).bind(login.at, login.country, login.browser),
89
+ db.prepare(`DELETE FROM ${T.logins} WHERE at < ?`).bind(login.at - ADMIN_LOGIN_KEEP_MS),
90
+ ]);
91
+ },
92
+ async recentLogins(limit) {
93
+ const n = Math.min(Math.max(Math.floor(Number(limit)) || 0, 0), 50);
94
+ const rows = (await db.prepare(`SELECT at, country, browser FROM ${T.logins} ORDER BY at DESC, id DESC LIMIT ?`).bind(n).all()).results;
95
+ return rows.map((r) => ({ at: Number(r.at), country: r.country, browser: r.browser }));
96
+ },
97
+ async clean(now) {
98
+ // The starter's clean-up of its admin rows (its `cleanUp`, without the game's own `hits`).
99
+ await db.batch([
100
+ db.prepare(`DELETE FROM ${T.requests} WHERE expires_at < ? AND scope = 'admin'`).bind(now - 2 * 3_600_000),
101
+ db.prepare(`DELETE FROM ${T.sessions} WHERE expires_at < ? AND scope = 'admin'`).bind(now),
102
+ db.prepare(`DELETE FROM ${T.logins} WHERE at < ?`).bind(now - ADMIN_LOGIN_KEEP_MS),
103
+ ]);
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,49 @@
1
+ import { type AdminSigninStore } from "../types.js";
2
+ export type AdminSigninDocSnap = {
3
+ exists: boolean;
4
+ data(): Record<string, unknown> | undefined;
5
+ };
6
+ export interface AdminSigninDocRef {
7
+ get(): Promise<AdminSigninDocSnap>;
8
+ }
9
+ export type AdminSigninQuerySnap = {
10
+ docs: {
11
+ id: string;
12
+ ref: AdminSigninDocRef;
13
+ data(): Record<string, unknown>;
14
+ }[];
15
+ };
16
+ export interface AdminSigninQuery {
17
+ where(field: string, op: "==" | "<", value: unknown): AdminSigninQuery;
18
+ orderBy(field: string, direction?: "asc" | "desc"): AdminSigninQuery;
19
+ limit(n: number): AdminSigninQuery;
20
+ get(): Promise<AdminSigninQuerySnap>;
21
+ }
22
+ export interface AdminSigninCollection extends AdminSigninQuery {
23
+ doc(id: string): AdminSigninDocRef;
24
+ }
25
+ /** A transaction: every read before the first write, as Firestore asks. */
26
+ export interface AdminSigninTransaction {
27
+ get(ref: AdminSigninDocRef): Promise<AdminSigninDocSnap>;
28
+ set(ref: AdminSigninDocRef, data: Record<string, unknown>, options?: {
29
+ merge?: boolean;
30
+ }): unknown;
31
+ delete(ref: AdminSigninDocRef): unknown;
32
+ }
33
+ export interface AdminSigninBatch {
34
+ set(ref: AdminSigninDocRef, data: Record<string, unknown>, options?: {
35
+ merge?: boolean;
36
+ }): unknown;
37
+ delete(ref: AdminSigninDocRef): unknown;
38
+ commit(): Promise<unknown>;
39
+ }
40
+ export interface FirestoreAdminSigninLike {
41
+ collection(name: string): AdminSigninCollection;
42
+ runTransaction<T>(fn: (tx: AdminSigninTransaction) => Promise<T>): Promise<T>;
43
+ batch(): AdminSigninBatch;
44
+ }
45
+ export declare function createFirestoreAdminSignin(db: FirestoreAdminSigninLike, collections?: {
46
+ requests?: string;
47
+ sessions?: string;
48
+ logins?: string;
49
+ }): AdminSigninStore;
@@ -0,0 +1,179 @@
1
+ // The admin sign-in's store for a game on Firestore (the Admin SDK). The package has no Firebase dependency: the adapter uses the small interface
2
+ // below, which the Admin SDK's `Firestore` fits. It reads and writes ONLY its three collections, and only the admin's documents (scope "admin") of the
3
+ // first two: the player sign-in's Firestore adapter keeps its own (scope "play") in the same collections.
4
+ //
5
+ // import { createFirestoreAdminSignin } from "@nano133/nanoterra/admin-signin/firestore";
6
+ // const store = createFirestoreAdminSignin(getFirestore());
7
+ //
8
+ // The data model (the three collection names can be changed):
9
+ // signin_requests/{key} { id, way, starter, status, expiresAt, nonce?, issuedAt?, label?, account?, delivered, scope: "admin", deleteAt }
10
+ // sessions/{idHash} { address, createdAt, expiresAt, via, scope: "admin", deleteAt }
11
+ // admin_logins/{id} { at, country, browser, deleteAt }
12
+ // Times are ms numbers; `deleteAt` is a Date (a Timestamp in Firestore): set a TTL policy on `deleteAt` for each collection. `{key}` is the request's id;
13
+ // `{idHash}` the SHA-256 of "admin:" and the session's token. Every change that reads first is a transaction. Indexes: Firestore's own single-field
14
+ // indexes are enough (`id`, `scope`, `expiresAt`, `at`).
15
+ //
16
+ // The admin store for Firestore (`createFirestoreStore`) has no sign-in list and no "Sign out everywhere": give it this sign-in's,
17
+ // `store.recentLogins = signin.recentLogins` and `store.signOutEverywhere = signin.signOutEverywhere`.
18
+ import { ADMIN_LOGIN_KEEP_MS, ADMIN_WAYS } from "../types.js";
19
+ const DAY = 86_400_000;
20
+ /** At most this many documents go in one batch (Firestore's limit is 500). */
21
+ const BATCH = 400;
22
+ const text = (v) => (typeof v === "string" ? v : undefined);
23
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
24
+ const hex = (bytes) => Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (x) => x.toString(16).padStart(2, "0")).join("");
25
+ function requestOf(d, key, way) {
26
+ if (!d || d.scope !== "admin" || d.way !== way || typeof d.starter !== "string" || num(d.expiresAt) === undefined)
27
+ return null;
28
+ const nonce = text(d.nonce);
29
+ const issuedAt = num(d.issuedAt);
30
+ const label = text(d.label);
31
+ const account = text(d.account);
32
+ return {
33
+ id: text(d.id) ?? key,
34
+ starter: d.starter,
35
+ status: d.status === "signedin" ? "signedin" : "waiting",
36
+ expiresAt: num(d.expiresAt),
37
+ ...(label !== undefined ? { label } : {}),
38
+ ...(nonce !== undefined ? { nonce } : {}),
39
+ ...(issuedAt !== undefined ? { issuedAt } : {}),
40
+ ...(account !== undefined ? { account } : {}),
41
+ };
42
+ }
43
+ export function createFirestoreAdminSignin(db, collections = {}) {
44
+ const requestsCol = () => db.collection(collections.requests ?? "signin_requests");
45
+ const sessionsCol = () => db.collection(collections.sessions ?? "sessions");
46
+ const loginsCol = () => db.collection(collections.logins ?? "admin_logins");
47
+ const sessionData = (s) => ({ address: s.address, createdAt: s.createdAt, expiresAt: s.expiresAt, via: s.via, scope: "admin", deleteAt: new Date(s.expiresAt) });
48
+ const stores = new Map();
49
+ for (const way of ADMIN_WAYS) {
50
+ stores.set(way, {
51
+ async create(key, r) {
52
+ const ref = requestsCol().doc(key);
53
+ await db.runTransaction(async (tx) => {
54
+ if ((await tx.get(ref)).exists)
55
+ throw new Error("a request with this key exists");
56
+ tx.set(ref, {
57
+ id: r.id,
58
+ way,
59
+ starter: r.starter,
60
+ status: "waiting",
61
+ expiresAt: r.expiresAt,
62
+ ...(r.nonce !== undefined ? { nonce: r.nonce } : {}),
63
+ ...(r.issuedAt !== undefined ? { issuedAt: r.issuedAt } : {}),
64
+ ...(r.label !== undefined ? { label: r.label } : {}),
65
+ delivered: false,
66
+ scope: "admin",
67
+ deleteAt: new Date(r.expiresAt + DAY),
68
+ });
69
+ });
70
+ },
71
+ async get(key) {
72
+ return requestOf((await requestsCol().doc(key).get()).data(), key, way);
73
+ },
74
+ async find(id) {
75
+ for (const d of (await requestsCol().where("id", "==", id).limit(10).get()).docs) {
76
+ const request = requestOf(d.data(), d.id, way);
77
+ if (request)
78
+ return { key: d.id, request };
79
+ }
80
+ return null;
81
+ },
82
+ async finish(key, account) {
83
+ const ref = requestsCol().doc(key);
84
+ return db.runTransaction(async (tx) => {
85
+ const cur = requestOf((await tx.get(ref)).data(), key, way);
86
+ if (!cur || cur.status !== "waiting")
87
+ return false;
88
+ tx.set(ref, { status: "signedin", account }, { merge: true });
89
+ return true;
90
+ });
91
+ },
92
+ });
93
+ }
94
+ async function deleteAll(docs) {
95
+ for (let i = 0; i < docs.length; i += BATCH) {
96
+ const b = db.batch();
97
+ for (const d of docs.slice(i, i + BATCH))
98
+ b.delete(d.ref);
99
+ await b.commit();
100
+ }
101
+ return docs.length;
102
+ }
103
+ return {
104
+ requests: (way) => {
105
+ const s = stores.get(way);
106
+ if (!s)
107
+ throw new Error(`no such way: ${String(way)}`);
108
+ return s;
109
+ },
110
+ async deliver(way, key, session) {
111
+ const ref = requestsCol().doc(key);
112
+ const sref = sessionsCol().doc(session.idHash);
113
+ return db.runTransaction(async (tx) => {
114
+ const raw = (await tx.get(ref)).data();
115
+ const cur = requestOf(raw, key, way);
116
+ await tx.get(sref);
117
+ if (!cur || cur.status !== "signedin" || raw?.delivered === true)
118
+ return false;
119
+ tx.set(ref, { delivered: true }, { merge: true });
120
+ tx.set(sref, sessionData(session));
121
+ return true;
122
+ });
123
+ },
124
+ async session(idHash, now) {
125
+ const d = (await sessionsCol().doc(idHash).get()).data();
126
+ if (!d || d.scope !== "admin" || typeof d.address !== "string")
127
+ return null;
128
+ const expiresAt = num(d.expiresAt);
129
+ if (expiresAt === undefined || expiresAt <= now)
130
+ return null;
131
+ return { idHash, address: d.address, createdAt: num(d.createdAt) ?? 0, expiresAt, via: String(d.via ?? "") };
132
+ },
133
+ async endSession(idHash) {
134
+ const ref = sessionsCol().doc(idHash);
135
+ await db.runTransaction(async (tx) => {
136
+ const d = (await tx.get(ref)).data();
137
+ if (d && d.scope === "admin")
138
+ tx.delete(ref);
139
+ });
140
+ },
141
+ async signOutEverywhere() {
142
+ // The requests first, then the sessions are read: a delivery that ran before the requests were ended wrote its session already, and it goes too; a
143
+ // delivery after it finds its request delivered (its transaction runs again on the changed document) and writes none.
144
+ const pending = (await requestsCol().where("scope", "==", "admin").get()).docs.filter((d) => d.data().delivered !== true);
145
+ for (let i = 0; i < pending.length; i += BATCH) {
146
+ const b = db.batch();
147
+ for (const d of pending.slice(i, i + BATCH))
148
+ b.set(d.ref, { delivered: true, expiresAt: 0 }, { merge: true });
149
+ await b.commit();
150
+ }
151
+ return deleteAll((await sessionsCol().where("scope", "==", "admin").get()).docs);
152
+ },
153
+ async recordLogin(login) {
154
+ const old = (await loginsCol().where("at", "<", login.at - ADMIN_LOGIN_KEEP_MS).limit(BATCH).get()).docs;
155
+ const b = db.batch();
156
+ b.set(loginsCol().doc(`${String(login.at).padStart(15, "0")}-${hex(8)}`), { at: login.at, country: login.country, browser: login.browser, deleteAt: new Date(login.at + ADMIN_LOGIN_KEEP_MS) });
157
+ for (const d of old)
158
+ b.delete(d.ref);
159
+ await b.commit();
160
+ },
161
+ async recentLogins(limit) {
162
+ const n = Math.min(Math.max(Math.floor(Number(limit)) || 0, 0), 50);
163
+ if (!n)
164
+ return [];
165
+ // Newest first; two at the same time come in the order of their ids (the time, then random letters).
166
+ const docs = (await loginsCol().orderBy("at", "desc").limit(n).get()).docs;
167
+ return docs.map((d) => {
168
+ const v = d.data();
169
+ return { at: num(v.at) ?? 0, country: text(v.country) ?? null, browser: text(v.browser) ?? "Unknown browser" };
170
+ });
171
+ },
172
+ async clean(now) {
173
+ const old = (await requestsCol().where("expiresAt", "<", now - 2 * 3_600_000).limit(BATCH).get()).docs.filter((d) => d.data().scope === "admin");
174
+ const ended = (await sessionsCol().where("expiresAt", "<", now).limit(BATCH).get()).docs.filter((d) => d.data().scope === "admin");
175
+ const logins = (await loginsCol().where("at", "<", now - ADMIN_LOGIN_KEEP_MS).limit(BATCH).get()).docs;
176
+ await deleteAll([...old, ...ended, ...logins]);
177
+ },
178
+ };
179
+ }
@@ -0,0 +1,4 @@
1
+ /** "Chrome on macOS", "Safari on iOS", "Firefox", or "Unknown browser". At most 40 characters, only from the lists above. */
2
+ export declare function shortBrowser(userAgent: string | null | undefined): string;
3
+ /** The rough country of a request: Cloudflare's two-letter country header, or null (not there, "XX" for unknown, "T1" for Tor, or anything that is not two capital letters). */
4
+ export declare function countryOf(req: Request): string | null;
@@ -0,0 +1,36 @@
1
+ // What the admin's sign-in list keeps of a browser: a short name made from fixed lists ("Chrome on macOS") and the rough country. The game starter's
2
+ // src/worker/browser.ts, unchanged. It NEVER returns any of the browser's own text, so a hostile browser text cannot put anything in the list, and only
3
+ // this name is kept (never the browser text itself).
4
+ const BROWSERS = [
5
+ [/\bEdg(?:e|A|iOS)?\//, "Edge"],
6
+ [/\bOPR\/|\bOpera\b/, "Opera"],
7
+ [/\bSamsungBrowser\//, "Samsung Internet"],
8
+ [/\bFirefox\/|\bFxiOS\//, "Firefox"],
9
+ [/\bChrome\/|\bCriOS\//, "Chrome"],
10
+ [/\bSafari\//, "Safari"],
11
+ ];
12
+ const SYSTEMS = [
13
+ [/\biPhone\b|\biPad\b|\biPod\b/, "iOS"],
14
+ [/\bAndroid\b/, "Android"],
15
+ [/\bCrOS\b/, "ChromeOS"],
16
+ [/\bWindows\b/, "Windows"],
17
+ [/\bMac OS X\b|\bMacintosh\b/, "macOS"],
18
+ [/\bLinux\b|\bX11\b/, "Linux"],
19
+ ];
20
+ /** "Chrome on macOS", "Safari on iOS", "Firefox", or "Unknown browser". At most 40 characters, only from the lists above. */
21
+ export function shortBrowser(userAgent) {
22
+ if (typeof userAgent !== "string" || !userAgent)
23
+ return "Unknown browser";
24
+ // Only the start of the text is read: a long text cannot make the matching slow.
25
+ const ua = userAgent.slice(0, 400);
26
+ const browser = BROWSERS.find(([re]) => re.test(ua))?.[1];
27
+ const system = SYSTEMS.find(([re]) => re.test(ua))?.[1];
28
+ if (!browser && !system)
29
+ return "Unknown browser";
30
+ return `${browser ?? "Browser"}${system ? ` on ${system}` : ""}`;
31
+ }
32
+ /** The rough country of a request: Cloudflare's two-letter country header, or null (not there, "XX" for unknown, "T1" for Tor, or anything that is not two capital letters). */
33
+ export function countryOf(req) {
34
+ const c = req.headers.get("CF-IPCountry");
35
+ return typeof c === "string" && /^[A-Z]{2}$/.test(c) && c !== "XX" && c !== "T1" ? c : null;
36
+ }