@nano133/nanoterra 0.8.1 → 0.9.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 +52 -0
- package/README.md +342 -6
- package/ai/SPEC.md +65 -3
- package/dist/admin/panel/mount.d.ts +3 -0
- package/dist/admin/panel/mount.js +4 -2
- package/dist/player-signin/adapters/d1.d.ts +10 -0
- package/dist/player-signin/adapters/d1.js +108 -0
- package/dist/player-signin/adapters/firestore.d.ts +45 -0
- package/dist/player-signin/adapters/firestore.js +168 -0
- package/dist/player-signin/conformance.d.ts +11 -0
- package/dist/player-signin/conformance.js +160 -0
- package/dist/player-signin/handler.d.ts +64 -0
- package/dist/player-signin/handler.js +364 -0
- package/dist/player-signin/host.d.ts +4 -0
- package/dist/player-signin/host.js +10 -0
- package/dist/player-signin/index.d.ts +9 -0
- package/dist/player-signin/index.js +15 -0
- package/dist/player-signin/memory.d.ts +15 -0
- package/dist/player-signin/memory.js +101 -0
- package/dist/player-signin/panel/index.d.ts +9 -0
- package/dist/player-signin/panel/index.js +7 -0
- package/dist/player-signin/panel/memo.d.ts +12 -0
- package/dist/player-signin/panel/memo.js +41 -0
- package/dist/player-signin/panel/mount.d.ts +21 -0
- package/dist/player-signin/panel/mount.js +105 -0
- package/dist/player-signin/panel/view.d.ts +45 -0
- package/dist/player-signin/panel/view.js +360 -0
- package/dist/player-signin/panel/words.d.ts +8 -0
- package/dist/player-signin/panel/words.js +26 -0
- package/dist/player-signin/scheme-check.d.ts +3 -0
- package/dist/player-signin/scheme-check.js +14 -0
- package/dist/player-signin/scheme.d.ts +13 -0
- package/dist/player-signin/scheme.js +28 -0
- package/dist/player-signin/types.d.ts +116 -0
- package/dist/player-signin/types.js +33 -0
- package/dist/shop/adapters/d1.d.ts +25 -0
- package/dist/shop/adapters/d1.js +200 -0
- package/dist/shop/adapters/firestore.d.ts +53 -0
- package/dist/shop/adapters/firestore.js +245 -0
- package/dist/shop/address.d.ts +14 -0
- package/dist/shop/address.js +104 -0
- package/dist/shop/amount.d.ts +26 -0
- package/dist/shop/amount.js +59 -0
- package/dist/shop/conformance.d.ts +13 -0
- package/dist/shop/conformance.js +203 -0
- package/dist/shop/handler.d.ts +76 -0
- package/dist/shop/handler.js +156 -0
- package/dist/shop/index.d.ts +19 -0
- package/dist/shop/index.js +21 -0
- package/dist/shop/match.d.ts +23 -0
- package/dist/shop/match.js +58 -0
- package/dist/shop/memory.d.ts +13 -0
- package/dist/shop/memory.js +106 -0
- package/dist/shop/nodes.d.ts +47 -0
- package/dist/shop/nodes.js +106 -0
- package/dist/shop/orders.d.ts +47 -0
- package/dist/shop/orders.js +82 -0
- package/dist/shop/panel/index.d.ts +7 -0
- package/dist/shop/panel/index.js +11 -0
- package/dist/shop/panel/looks.d.ts +30 -0
- package/dist/shop/panel/looks.js +61 -0
- package/dist/shop/panel/mount.d.ts +52 -0
- package/dist/shop/panel/mount.js +194 -0
- package/dist/shop/panel/pay.d.ts +24 -0
- package/dist/shop/panel/pay.js +115 -0
- package/dist/shop/panel/qr.d.ts +9 -0
- package/dist/shop/panel/qr.js +281 -0
- package/dist/shop/price.d.ts +23 -0
- package/dist/shop/price.js +43 -0
- package/dist/shop/scan.d.ts +24 -0
- package/dist/shop/scan.js +80 -0
- package/dist/shop/security.d.ts +10 -0
- package/dist/shop/security.js +56 -0
- package/dist/shop/store.d.ts +101 -0
- package/dist/shop/store.js +4 -0
- package/dist/shop/worker.d.ts +43 -0
- package/dist/shop/worker.js +76 -0
- package/package.json +21 -6
- package/src/admin/panel/mount.ts +7 -2
- package/src/player-signin/adapters/d1.sql +45 -0
- package/src/player-signin/adapters/d1.ts +120 -0
- package/src/player-signin/adapters/firestore.ts +194 -0
- package/src/player-signin/conformance.ts +184 -0
- package/src/player-signin/handler.ts +404 -0
- package/src/player-signin/host.ts +12 -0
- package/src/player-signin/index.ts +20 -0
- package/src/player-signin/memory.ts +94 -0
- package/src/player-signin/panel/index.ts +12 -0
- package/src/player-signin/panel/memo.ts +49 -0
- package/src/player-signin/panel/mount.ts +127 -0
- package/src/player-signin/panel/style.css +473 -0
- package/src/player-signin/panel/view.ts +428 -0
- package/src/player-signin/panel/words.ts +32 -0
- package/src/player-signin/scheme-check.ts +14 -0
- package/src/player-signin/scheme.ts +32 -0
- package/src/player-signin/types.ts +103 -0
- package/src/shop/adapters/d1.sql +52 -0
- package/src/shop/adapters/d1.ts +245 -0
- package/src/shop/adapters/firestore.ts +281 -0
- package/src/shop/address.ts +102 -0
- package/src/shop/amount.ts +66 -0
- package/src/shop/conformance.ts +232 -0
- package/src/shop/handler.ts +202 -0
- package/src/shop/index.ts +30 -0
- package/src/shop/match.ts +63 -0
- package/src/shop/memory.ts +102 -0
- package/src/shop/nodes.ts +115 -0
- package/src/shop/orders.ts +93 -0
- package/src/shop/panel/index.ts +15 -0
- package/src/shop/panel/looks.ts +106 -0
- package/src/shop/panel/mount.ts +241 -0
- package/src/shop/panel/pay.ts +171 -0
- package/src/shop/panel/qr.ts +256 -0
- package/src/shop/panel/style.css +534 -0
- package/src/shop/price.ts +52 -0
- package/src/shop/scan.ts +90 -0
- package/src/shop/security.ts +50 -0
- package/src/shop/store.ts +99 -0
- package/src/shop/worker.ts +107 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 (2026-11-01)
|
|
4
|
+
|
|
5
|
+
- **The player's sign-in** (`@nano133/nanoterra/player-signin`, `/player-signin/conformance`, `/player-signin/d1` with `/player-signin/d1.sql`,
|
|
6
|
+
`/player-signin/firestore`, `/player-signin/panel` and `/player-signin/style.css`). Wes: "start the player passkey sign-in too" (2026-11-01); the kit's change
|
|
7
|
+
list asked for "a passkey sign-in for players" (a prompt-pack run found that players without a wallet app cannot buy).
|
|
8
|
+
- **A passkey** (the passkey purse of `@nano133/signin`: a Nano account made in the browser from the passkey's PRF output, by the game's own FROZEN scheme)
|
|
9
|
+
and the three **wallet apps** (Berry Wallet, NanChat, Nautilus), in one path. The server checks signatures only and holds no key; a player is a Nano
|
|
10
|
+
address whichever way they sign in. `@nano133/signin` (`^0.5.0`) is a new **optional** peer, needed by this path only.
|
|
11
|
+
- **The server** (`createPlayerSignin({ store, ... })`): the game starter's routes and answers (`/api/signin/start`, `/nano/:id`, `/nautilus`, `/nanchat`,
|
|
12
|
+
`/check`, `/api/signout`) plus `POST /api/signin/passkey`; the starter's session cookie (`session`, `__Host-session` on https, 30 days, the token's SHA-256
|
|
13
|
+
kept); `player(request)` for the shop and the friends list, `session`, `signIn` (the game's own code exchange), `signOut`, `forget` ("Delete my data") and
|
|
14
|
+
`clean`. `sharedPaths` leaves the admin's calls to the game. `schemeVector` and `checkScheme` for the scheme's fixed vectors.
|
|
15
|
+
- **The stores:** memory, D1 (the starter's own `signin_requests` and `sessions`, plus `signin_once`: a starter's players stay signed in; admin rows are never
|
|
16
|
+
touched) and Firestore (a small interface, no Firebase dependency), with `playerSigninStoreConformance`.
|
|
17
|
+
- **The sheet** (`mountPlayerSignin`, `signInView`): passkey first, then the wallet apps; no passkey on a preview address (`*.workers.dev`, `previewHost`;
|
|
18
|
+
`passkey: true` offers it there), where a passkey would be lost when the game moves to its own domain; "Make a passkey" and "Use my passkey", "Keep a spare key" after a new
|
|
19
|
+
passkey, the faults in plain words (no PRF and the rest), QR or one key per wallet, phone first, light and dark. The purse's signing code loads only at a
|
|
20
|
+
passkey sign-in.
|
|
21
|
+
- **The admin** (`mountAdmin`, `playerPasskeys: true`): on `*.workers.dev` its "Check these" banner says "Passkeys turn on when your game has its own domain."
|
|
22
|
+
- **A shop for a game's looks** (`@nano133/nanoterra/shop`, `/shop/d1`, `/shop/d1.sql`, `/shop/firestore`, `/shop/worker`, `/shop/conformance`,
|
|
23
|
+
`/shop/panel` and `/shop/style.css`). Wes: "start the shop kit path" (2026-11-01). A game installs the game starter's shop instead of copying its
|
|
24
|
+
2,600 lines. The starter's money model, unchanged: the money goes straight from the player's wallet to the creator's address, the server holds
|
|
25
|
+
no key and only reads the ledger, and each order asks for a unique, clean amount (at most 4 decimals of XNO) that a payment must match exactly.
|
|
26
|
+
- **The rules** (no DOM, no database): the starter's amounts (raw text and BigInt, prices rounded up), the reservation (one step more while
|
|
27
|
+
another held order has the amount, at most a tenth more; an amount held until an hour after the order ends), the match (a confirmed send of
|
|
28
|
+
exactly the amount to exactly the address, first seen after the order; late is "paid late", no look), the price (the median of three feeds,
|
|
29
|
+
kept 5 minutes, the last good one for 6 hours), the nodes with failover (`nodesFor`: Nanswap first with a key, then the game's own list or
|
|
30
|
+
the public nodes), and the payment scan (`scanPayments`: one scan for the whole game every 4 s, fails closed). The creator's address is
|
|
31
|
+
checked with its checksum (`validAddress`, the package's own 5-byte BLAKE2b): a typo keeps test mode on.
|
|
32
|
+
- **The routes** (`createShopHandler`): the starter's paths and JSON (`POST /api/orders`, `GET /api/orders/{id}`, `POST /api/orders/{id}/check`)
|
|
33
|
+
and its words for each refusal, the game's player through `player(request)`, the same-origin check on POST, a limit of new orders and the person
|
|
34
|
+
check as hooks. **Test mode** until the owner's own address is set AND test mode is off: no address and no link in the answer.
|
|
35
|
+
- **The stores**, each keeping the rules in one step: memory, D1 on the starter's own tables (`createD1Shop`; `d1.sql` with `IF NOT EXISTS`: a
|
|
36
|
+
game from the starter switches with no migration and keeps its orders and owned looks; `d1Limit` and `visitorKey` on the same rows), and
|
|
37
|
+
Firestore (`createFirestoreShop`: the admin's orders, owned and meta, plus a document for each held amount and each recorded payment, in
|
|
38
|
+
transactions). `forget(player)` for "Delete my data": waits while a payment may still come; deletes the owned looks and the unpaid orders;
|
|
39
|
+
keeps a paid order as the sales record under "deleted", and a late payment's payer (the creator sends it back by hand).
|
|
40
|
+
- **The conformance suite** (`shopStoreConformance`, 17 short cases with `SHOP_CASE_TIMEOUT_MS`): one owner per order, a paid order never paid
|
|
41
|
+
twice, one payment for one order, an expired order past its hold never paid, unique amounts among held orders (25 at once), busy, 39-digit
|
|
42
|
+
amounts, the scan's claim, the price, and `forget`.
|
|
43
|
+
- **The Worker** (`createShop(env, { catalogue, player })`): the starter's settings (`RECEIVE_ADDRESS`, `TEST_MODE`, `NODE_API_KEY`, `NANO_NODES`,
|
|
44
|
+
`TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET`), Turnstile's check, the limit in D1, `scan()` for the cron, `config()` for the page and `status()`
|
|
45
|
+
(with the starter's warnings) for the admin.
|
|
46
|
+
- **The panel** (`mountShop`): the Looks key (or `key: false` and `open()`), the looks sheet with a tab for each wear slot, Buy through the
|
|
47
|
+
game's `signIn()` (the shop draws no sign-in), the pay sheet with the package's own QR code (SVG, byte mode, level M; the same modules as
|
|
48
|
+
the starter's qrcode-generator), the starter's "TEST MODE: DO NOT PAY" stamp with the wallet key off, a check every 5 s for 2 minutes, and
|
|
49
|
+
`onOwned(lookId)`. Phone first; on a phone held sideways the QR code stands alone on the left and the wallet key stays in view. Light and
|
|
50
|
+
dark with the `--n133-*` variables and their `--n133-dark-*` twins.
|
|
51
|
+
- **The CSP lists** (`SHOP_CSP`, `mergeCsp`): Turnstile's origin for `script-src` and `frame-src`, merged into the game's own policy.
|
|
52
|
+
- Ideas from Echo Island's shop (a tab for each wear slot with its plain choice first; the wallet key in a foot that stays in view) and from the
|
|
53
|
+
prompt pack's runs (the shop's tests read a fixture, not a game's looks; the conformance cases are short and each its own test).
|
|
54
|
+
|
|
3
55
|
## 0.8.1 (2026-11-01)
|
|
4
56
|
|
|
5
57
|
- **A closed panel gives the keys back to the game.** The options panel, the friends panel and the emote wheel put the focus back on their key when they
|
package/README.md
CHANGED
|
@@ -12,10 +12,12 @@ themselves. This package gives your game these things:
|
|
|
12
12
|
- **Held input that does not stick** (`/input`, since 0.3.1): lets go of the keys and the stick when the player leaves the page, so the character never walks on by itself, and pauses a free round.
|
|
13
13
|
- **A friends list** (`/friends`, since 0.6.0): a Friends key that holds the player's Nanoterra friends (a nano133 window), or a full friends list of the game's own players on the game's own database, as the admin's Game card chooses.
|
|
14
14
|
- **An options menu** (`/options`, since 0.8.0): the game's settings as data, with defaults for each device, kept in the player's browser, and a ready Options key and panel (Basic and Advanced); common settings to opt into, and a section another module adds.
|
|
15
|
+
- **The player's sign-in** (`/player-signin`, since 0.9.0): a passkey (Face ID, Touch ID or the device PIN) or a wallet app (Berry Wallet, NanChat, Nautilus), the sessions and a ready sign-in sheet, for a game with a server. It needs `@nano133/signin` (an optional peer).
|
|
15
16
|
- **Emotes** (`/emotes`, since 0.8.0): Nano Island's eleven emotes on the player's character (the Wave, Cheer, Clap, Dance and seven more, made in Blender), and the emote wheel of eight that the player picks, which also plays a game's own animations.
|
|
17
|
+
- **A shop** (`/shop`, since 0.9.0): sells looks for Nano (XNO) straight to the creator's address, with no key on the server: the routes, the payment check, D1 and Firestore stores, and the Looks and pay sheets (QR code, test mode stamp).
|
|
16
18
|
|
|
17
19
|
It replaces `@nano133/character` 0.1.0, `@nano133/ticket` 0.1.0 and `@nano133/looks` 0.1.0
|
|
18
|
-
(see "Moving from the three old packages").
|
|
20
|
+
(see "Moving from the three old packages"). The signature checks, checkout and late returns stay in their own packages
|
|
19
21
|
([`@nano133/signin`](../signin), [`@nano133/checkout`](../checkout), [`@nano133/late-returns`](../late-returns)).
|
|
20
22
|
|
|
21
23
|
nano133.com's island and its 3D room draw the player with **this same code**: the geometry, the skeleton, the colours and the record
|
|
@@ -29,7 +31,8 @@ Pre-alpha means: the shape of the API may still change, and the character has no
|
|
|
29
31
|
npm install @nano133/nanoterra three
|
|
30
32
|
```
|
|
31
33
|
|
|
32
|
-
`three` is an **optional** peer dependency (`>=0.174.0`). A game that only reads a ticket does not need it.
|
|
34
|
+
`three` is an **optional** peer dependency (`>=0.174.0`). A game that only reads a ticket does not need it. `@nano133/signin` is the other optional peer:
|
|
35
|
+
only the player's sign-in (`/player-signin`) needs it (`npm install @nano133/nanoterra @nano133/signin`).
|
|
33
36
|
|
|
34
37
|
## Quick start
|
|
35
38
|
|
|
@@ -80,8 +83,18 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
|
|
|
80
83
|
| `@nano133/nanoterra/emotes` | the emotes' list, the wheel's eight places, the multiplayer message and the player's options: no DOM, no Three.js (since 0.8.0) | no | 1.4 kB |
|
|
81
84
|
| `@nano133/nanoterra/emotes/player` | the emotes on the kit's character: `createEmotes`, `playRemote`, `loadEmotes` (since 0.8.0) | yes | 2.9 kB; the clips load on the first emote, a chunk of their own: 84 kB, 18.8 kB gzip |
|
|
82
85
|
| `@nano133/nanoterra/emotes/wheel` | the Emotes key and the emote wheel: `mountEmotes`, and its stylesheet `@nano133/nanoterra/emotes/style.css` (since 0.8.0) | no | 5.9 kB + 7.4 kB of CSS (minified); the pictures (`/emotes/icons.webp`, 34 kB) load when the wheel first opens |
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
| `@nano133/nanoterra/player-signin` | the player's sign-in: the handler, the sessions, `player(request)`, `forget`, a memory store, `schemeVector` (server code; it needs `@nano133/signin`; since 0.9.0) | no | 5.1 kB without `@nano133/signin` |
|
|
87
|
+
| `@nano133/nanoterra/player-signin/d1`, `/player-signin/firestore` | the store for D1 (with the migration `/player-signin/d1.sql`) or Firestore (a small interface, no Firebase dependency) (since 0.9.0) | no | 1.2 kB, 1.3 kB |
|
|
88
|
+
| `@nano133/nanoterra/player-signin/panel` | the sign-in sheet, and its stylesheet `@nano133/nanoterra/player-signin/style.css` (since 0.9.0) | no | 8.7 kB with `@nano133/signin/passkey` + 8.1 kB of CSS (minified); the purse's signing code loads at a passkey sign-in, a chunk of its own: 39.8 kB gzip |
|
|
89
|
+
| `@nano133/nanoterra/player-signin/conformance` | the store's rules as test cases, for a store a game writes itself (since 0.9.0) | no | n/a |
|
|
90
|
+
|
|
91
|
+
| `@nano133/nanoterra/shop` | the shop's rules and routes, the payment scan, a memory store, the CSP lists (server code; since 0.9.0) | no | 9.1 kB (all), 7.1 kB (`createShopHandler` alone) |
|
|
92
|
+
| `@nano133/nanoterra/shop/d1`, `/shop/firestore` | the shop's store on D1 (the starter's tables, `/shop/d1.sql`) or Firestore: `createD1Shop`, `createFirestoreShop` (since 0.9.0) | no | 2.4 kB, 2.2 kB |
|
|
93
|
+
| `@nano133/nanoterra/shop/worker` | the shop for a Worker with D1, from the starter's settings: `createShop` (since 0.9.0) | no | 10.0 kB (with the D1 store and Turnstile) |
|
|
94
|
+
| `@nano133/nanoterra/shop/panel` | the Looks key, the looks sheet and the pay sheet with its own QR code: `mountShop`, and its stylesheet `@nano133/nanoterra/shop/style.css` (since 0.9.0) | no | 9.3 kB + 2.5 kB of CSS (gzip; 8.5 kB minified) |
|
|
95
|
+
| `@nano133/nanoterra/shop/conformance` | the shop's money rules as test cases, for a store a game writes itself (since 0.9.0) | no | n/a |
|
|
96
|
+
|
|
97
|
+
Measured 2026-10-26 (the friends paths 2026-10-30, the options, the emotes, the player sign-in and the shop paths 2026-11-01): `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
|
|
85
98
|
All four paths together: 23.6 kB. A test of the package proves that the record, the ticket, the looks and the small part import no package at all.
|
|
86
99
|
|
|
87
100
|
## The character
|
|
@@ -633,7 +646,8 @@ look, with their looks. **Settings**: the **Game** card (the character and sign-
|
|
|
633
646
|
real tabs (the arrow keys, Home and End work); every tab is made once, so a draft, a search and a switch keep their state, and the panel never writes the page's address.
|
|
634
647
|
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
|
|
635
648
|
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`,
|
|
636
|
-
`loginsNote`
|
|
649
|
+
`loginsNote`, `playerPasskeys` (the game offers its players a passkey: on `*.workers.dev` the banner says that passkeys turn on with the game's own
|
|
650
|
+
domain) and `fetch`.
|
|
637
651
|
|
|
638
652
|
**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¢ ·
|
|
639
653
|
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
|
|
@@ -1014,6 +1028,328 @@ or `store.add(EMOTE_OPTIONS)`; `EMOTE_OPTION_DEFS` holds the two toggles with sh
|
|
|
1014
1028
|
`{ t: "react", k: "dance" }` from a player, checks the id against its own list and sends the others `{ t: "react", id: <player>, k: "dance" }`; an old server that
|
|
1015
1029
|
does not know an id drops it, and nothing breaks.
|
|
1016
1030
|
|
|
1031
|
+
## The player's sign-in (`/player-signin`; since 0.9.0)
|
|
1032
|
+
|
|
1033
|
+
The PLAYER's sign-in for a game with a server, in one path: **a passkey** (Face ID, Touch ID or the device PIN: no app to install) and the three
|
|
1034
|
+
**wallet apps** (Berry Wallet, NanChat, Nautilus). No password, no email and no payment, and **the server holds no key**: every way is a signature from a
|
|
1035
|
+
key the player holds, and the server checks it with [`@nano133/signin`](../signin), an optional peer of this package:
|
|
1036
|
+
|
|
1037
|
+
```sh
|
|
1038
|
+
npm install @nano133/nanoterra @nano133/signin
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
A player is a **Nano address**, whichever way they sign in. The shop (`/shop`) and the friends list key a player by it, so a look a player buys stays with
|
|
1042
|
+
that address. The admin's sign-in is another thing (`/admin`): this path never makes an admin login.
|
|
1043
|
+
|
|
1044
|
+
### The passkey: the passkey purse
|
|
1045
|
+
|
|
1046
|
+
The passkey gives the page 32 secret bytes (its **PRF** extension), and those bytes become the seed of a Nano account in the browser: the player's
|
|
1047
|
+
address. The browser signs a short text that names the game's host with that account, and the server checks the signature (`POST {base}/passkey`). The
|
|
1048
|
+
server keeps no credential and checks no assertion: the proof is the signature, as for a wallet. The seed never leaves the page and is never stored.
|
|
1049
|
+
|
|
1050
|
+
- **The scheme is the game's own, and it is FROZEN.** A player's account (and so their looks) lies at the address these exact texts give:
|
|
1051
|
+
|
|
1052
|
+
```ts
|
|
1053
|
+
// FROZEN: never edit. A new scheme is a new versionByte with new texts.
|
|
1054
|
+
export const SCHEME = { input: "mygame.com player v1", salt: "mygame.com", info: "mygame.com player seed v1", versionByte: 1 };
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
Write it once, in one file, and test it with **fixed vectors** that must never change (a PRF output, its seed, its address):
|
|
1058
|
+
|
|
1059
|
+
```ts
|
|
1060
|
+
import { schemeVector } from "@nano133/nanoterra/player-signin";
|
|
1061
|
+
assert.deepEqual(await schemeVector(SCHEME, "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"), { seed: "…", address: "nano_…" });
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
Run it once to read the seed and the address, write them into the test, and never change them. The kit gives no default scheme on purpose.
|
|
1065
|
+
- **A passkey belongs to one domain** (its relying party id: the page's host, or `rpId`). Moving the game to another domain gives every player another
|
|
1066
|
+
account. Choose the domain before the first player. So the sheet offers **no passkey on a preview address** (`*.workers.dev`, `previewHost`), where the game
|
|
1067
|
+
starter runs first: the wallet apps only, until the game has its own domain (`passkey: true` offers it there anyway). The admin says so in its "Check these"
|
|
1068
|
+
banner when `mountAdmin` gets `playerPasskeys: true`: "Passkeys turn on when your game has its own domain."
|
|
1069
|
+
- **It needs PRF** in the browser and in the passkey's provider. Where the browser says it has none, the passkey shows last ("Not on this browser"); where a
|
|
1070
|
+
passkey gives no PRF answer, the sheet says so in plain words and offers the wallet apps. As known in 2026-11, not tested here on each device (the sheet
|
|
1071
|
+
asks the browser, `getClientCapabilities`, and never guesses):
|
|
1072
|
+
Safari on iOS 18 and macOS 15 and later with iCloud Keychain, Chrome on Android with Google Password Manager, Chrome on a computer with Google Password
|
|
1073
|
+
Manager or iCloud Keychain (macOS 15), 1Password and recent Bitwarden: yes. Windows Hello and older systems: often no. Firefox: from 135, where the system's
|
|
1074
|
+
provider has it. A phone used through the computer's QR code (hybrid) gives its own PRF.
|
|
1075
|
+
- **The spare key.** The player's looks belong to the passkey's address. A synced passkey (iCloud Keychain, Google Password Manager) survives a lost phone;
|
|
1076
|
+
a lost passkey with no sync loses the address and its looks (no XNO is in it: the payments go straight to the creator). So after a NEW passkey the sheet
|
|
1077
|
+
shows **"Keep a spare key"**: the seed, at the press only, and how to use it (import it as a seed in a Nano wallet such as Nautilus, then sign in with that
|
|
1078
|
+
wallet: the same address). It can be skipped, not missed. `spareKey: false` turns it off (a game for small children, say). Signing in with the wallet that
|
|
1079
|
+
PAID does not bring the looks back: a look belongs to the address that was signed in when it was bought. Linking a wallet to a passkey account (one
|
|
1080
|
+
player, two ways in) is planned for a later version.
|
|
1081
|
+
|
|
1082
|
+
### The server
|
|
1083
|
+
|
|
1084
|
+
```ts
|
|
1085
|
+
import { createPlayerSignin } from "@nano133/nanoterra/player-signin";
|
|
1086
|
+
import { createD1PlayerSignin } from "@nano133/nanoterra/player-signin/d1";
|
|
1087
|
+
|
|
1088
|
+
const signin = createPlayerSignin({
|
|
1089
|
+
store: createD1PlayerSignin(env.DB), // or createFirestorePlayerSignin(getFirestore()), or createMemoryPlayerSignin() for a test
|
|
1090
|
+
siteAddress: env.RECEIVE_ADDRESS, // Nautilus only NAMES it in its request; no key, no payment
|
|
1091
|
+
limit: (key, n, ms, now) => d1Limit(key, n, ms, now), // your limiter (default: this copy's memory)
|
|
1092
|
+
visitor: (req) => visitorKey(req), // a keyed hash of the IP address (default: the CF-Connecting-IP header)
|
|
1093
|
+
personCheck: (token, req) => turnstileOk(token, req), // optional (Turnstile)
|
|
1094
|
+
});
|
|
1095
|
+
const answer = await signin.handle(request); // null when the request is not a sign-in route
|
|
1096
|
+
if (answer) return answer;
|
|
1097
|
+
const player = await signin.player(request); // the signed-in player's address, or null: the shop's and the friends' `player` hook
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
| Route (`base` "/api/signin") | Who calls it | What it does |
|
|
1101
|
+
|---|---|---|
|
|
1102
|
+
| `POST {base}/start` `{ way, turnstile? }` | the page | starts a wallet request: `{ way, id, link, walletLink?, code?, expiresAt }` and the starter cookie (30 minutes) |
|
|
1103
|
+
| `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) |
|
|
1104
|
+
| `POST {base}/nautilus?id=` | Nautilus | its signature (always 200: Nautilus shows only a 200's words) |
|
|
1105
|
+
| `POST {base}/nanchat` | NanChat's relay server | its signature (always 200; a refusal starts "NOT signed in"); limited in total and per nonce, never per visitor |
|
|
1106
|
+
| `POST {base}/check` `{ id, way }` | the page, every 2 s | `{ status }`: `waiting`, `expired`, `elsewhere` (another browser started it), `used`, or `signedin` with `address` and the session cookie |
|
|
1107
|
+
| `POST {base}/passkey` `{ address, at, signature, turnstile? }` | the page | the passkey purse's signature: `{ status: "signedin", address }` and the session cookie |
|
|
1108
|
+
| `POST /api/signout` | the page | ends this browser's session: `{ ok: true }` |
|
|
1109
|
+
|
|
1110
|
+
These are the game starter's own routes and answers, so its page works unchanged. The rules:
|
|
1111
|
+
|
|
1112
|
+
- The signed texts name the game's **host** (the request's, or `domain`: with `domain` set, a call to another host is refused, 403). A signature made for
|
|
1113
|
+
another site counts nowhere here.
|
|
1114
|
+
- **Only the browser that started a wallet request is signed in by it** (its starter cookie), **once**: the delivery and its session are one step of the store.
|
|
1115
|
+
- A passkey signature counts **once** (by the hash of its text) and only within 5 minutes either way of its time: a replay is 409, a stale one 400.
|
|
1116
|
+
- The page's calls must come from the game's own page (Origin), 403 otherwise; the wallets' calls have no cookie and no Origin. Starts and passkey sign-ins:
|
|
1117
|
+
20 in 10 minutes per visitor (`limit`); checks 90 a minute; the wallets' calls 30 a minute per IP address. A store's fault is a plain 500 (`onError` hears it).
|
|
1118
|
+
- **The session** is the starter's: the cookie `session` (`__Host-session` on https; HttpOnly, SameSite=Lax, 30 days). The store keeps only the SHA-256 of
|
|
1119
|
+
its token and `via`: `"wallet"`, `"passkey"` or `"nanoterra"`.
|
|
1120
|
+
- `signin.session(request)` gives `{ address, createdAt, via }`; `signin.signIn(request, address, "nanoterra")` makes a session for the game's own code
|
|
1121
|
+
exchange and returns the Set-Cookie value; `signin.signOut(request)` likewise; `signin.forget(player)` is for **"Delete my data"** (every session, signed
|
|
1122
|
+
request and once-only row of the player); `signin.clean()` is for a cron.
|
|
1123
|
+
- **`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
|
|
1124
|
+
request this store does not hold, answer null, and the game's admin code answers them.
|
|
1125
|
+
- `ways` (default all three), `passkey: false`, `base`, `signOutPath`, `cookies`, `sessionDays` change the rest.
|
|
1126
|
+
|
|
1127
|
+
**The stores** read and write only their three tables or collections, and only the player's rows:
|
|
1128
|
+
|
|
1129
|
+
- **D1:** add `@nano133/nanoterra/player-signin/d1.sql` to the migrations. Its `signin_requests` and `sessions` are the game starter's own tables (`CREATE
|
|
1130
|
+
TABLE IF NOT EXISTS`): on the starter it adds only `signin_once` and three indexes, and **every player stays signed in**. Rows of the scope `'admin'` are
|
|
1131
|
+
never read, delivered, ended or deleted. Other table names: `createD1PlayerSignin(db, { requests, sessions, once })`, and the same names in the SQL.
|
|
1132
|
+
- **Firestore:** `createFirestorePlayerSignin(db, { requests, sessions, once })` (default `signin_requests`, `sessions`, `signin_once`). Each document
|
|
1133
|
+
carries `deleteAt`: set a TTL policy on it for each collection. Firestore's own single-field indexes are enough.
|
|
1134
|
+
- **Your own database:** a `PlayerSigninStore` that passes `playerSigninStoreConformance` from `@nano133/nanoterra/player-signin/conformance`.
|
|
1135
|
+
|
|
1136
|
+
### The sheet
|
|
1137
|
+
|
|
1138
|
+
```ts
|
|
1139
|
+
import { mountPlayerSignin } from "@nano133/nanoterra/player-signin/panel";
|
|
1140
|
+
import "@nano133/nanoterra/player-signin/style.css";
|
|
1141
|
+
import { SCHEME } from "./scheme"; // the frozen scheme above
|
|
1142
|
+
|
|
1143
|
+
const signin = mountPlayerSignin(document.body, {
|
|
1144
|
+
name: "My Game", // the device shows it next to the passkey: "My Game · 1 Nov 2026"
|
|
1145
|
+
scheme: SCHEME,
|
|
1146
|
+
qr: (text) => qrCanvas(text), // a QR code for a wallet's link on a computer (no hook: the link as text)
|
|
1147
|
+
personCheck: (box) => turnstileToken(box), // optional
|
|
1148
|
+
onSignedIn: (player) => refresh(), // { address, via: "wallet" | "passkey" }
|
|
1149
|
+
});
|
|
1150
|
+
const player = await signin.open(); // the shop's "sign in first": the player, or null when the sheet closed
|
|
1151
|
+
```
|
|
1152
|
+
|
|
1153
|
+
- **Passkey first** ("Use a passkey", "Face ID, Touch ID or your device PIN"), then the three wallet apps as tiles. The passkey step has "Use my passkey" and
|
|
1154
|
+
"Make a passkey"; the main key is "Use" in a browser that made or used one before, and the device refuses a second passkey here ("This device has your
|
|
1155
|
+
passkey already. Use it."). A new passkey is used at once (its seed comes from a USE, never from the answer at create); a browser that allows one prompt
|
|
1156
|
+
per press shows one key, "Open my passkey".
|
|
1157
|
+
- The faults, in plain words: no passkey used, no PRF, no passkeys in this browser, a password manager's answer in a form the kit does not read, another
|
|
1158
|
+
game's passkey, another passkey than the one just made, two answers for one passkey, the wrong host. None names a seed or a key; none says to delete a passkey.
|
|
1159
|
+
- A wallet: on a touch screen one key that opens the app; with a mouse its QR code. The page asks every 2 s, at most 5 minutes.
|
|
1160
|
+
- `signInView(options)` is the same content for a game with a sheet of its own (`{ node, stop, reset }`).
|
|
1161
|
+
- Phone first: every key 44 px or more, the sheet from the bottom on a phone held upright, a card in the middle on a wide or sideways screen; Escape, the close
|
|
1162
|
+
key and the scrim close it; no browser dialog. Light and dark like the options panel (`data-n133-theme` on `<html>`, else the device), every colour a
|
|
1163
|
+
`--n133-*` variable with a `--n133-dark-*` twin; `--n133-signin-z` (default 1200) for its z-index.
|
|
1164
|
+
- The page loads the purse's signing code (`@nano133/signin/purse`, 40 kB gzip) only when a passkey signs in.
|
|
1165
|
+
- The browser remembers, in `localStorage["n133.player-signin.v1:<rpId>"]`, the ids of its passkeys, which were made here and never opened, and the last
|
|
1166
|
+
way: no secret and no address.
|
|
1167
|
+
|
|
1168
|
+
## The shop (`/shop`; since 0.9.0)
|
|
1169
|
+
|
|
1170
|
+
A shop that sells **looks** for Nano (XNO), the way the [game starter](https://github.com/nano133-com/nano-game-starter) sells them. A game
|
|
1171
|
+
installs it instead of copying the starter's shop.
|
|
1172
|
+
|
|
1173
|
+
**The money model.** The money goes **straight from the player's wallet to the creator's address**. The server holds **no key** and sends
|
|
1174
|
+
nothing: it only reads the Nano ledger to see that a payment came. Each order asks for a unique, clean amount (the price rounded up to
|
|
1175
|
+
0.0001 XNO, one step more while another order holds that amount), and a payment counts only when it is a confirmed send of exactly that
|
|
1176
|
+
amount to exactly that address, first seen after the order was made. (This is not `@nano133/checkout`'s address for each checkout: that
|
|
1177
|
+
one puts a key on the server.)
|
|
1178
|
+
|
|
1179
|
+
| Path | What |
|
|
1180
|
+
|---|---|
|
|
1181
|
+
| `@nano133/nanoterra/shop` | the rules (amounts in raw text and BigInt, the reservation, the match, the price, the nodes, the address checksum), the routes (`createShopHandler`), the payment scan (`scanPayments`), a memory store, the CSP lists. No DOM, no database, no package. |
|
|
1182
|
+
| `@nano133/nanoterra/shop/d1`, `/shop/d1.sql` | the store on Cloudflare D1, on the starter's own tables (`createD1Shop`), and `d1Limit` and `visitorKey` |
|
|
1183
|
+
| `@nano133/nanoterra/shop/firestore` | the store on Firestore (a small interface, no Firebase dependency): `createFirestoreShop` |
|
|
1184
|
+
| `@nano133/nanoterra/shop/worker` | the shop for a Worker with D1, set up from the starter's settings: `createShop(env, { catalogue, player })` |
|
|
1185
|
+
| `@nano133/nanoterra/shop/panel`, `/shop/style.css` | the Looks key, the looks sheet and the pay sheet (QR code, test stamp), in plain DOM: `mountShop` |
|
|
1186
|
+
| `@nano133/nanoterra/shop/conformance` | the money rules as test cases, for a store a game writes itself |
|
|
1187
|
+
|
|
1188
|
+
### On a Worker with D1 (the starter's way)
|
|
1189
|
+
|
|
1190
|
+
```ts
|
|
1191
|
+
import { createShop } from "@nano133/nanoterra/shop/worker";
|
|
1192
|
+
import looks from "../../looks.json";
|
|
1193
|
+
|
|
1194
|
+
// In the Worker's fetch, after the game's own sign-in has read the PLAYER session:
|
|
1195
|
+
const shop = createShop(env, { catalogue: looks, player: async () => session?.address ?? null });
|
|
1196
|
+
const answer = await shop.handle(request); // POST /api/orders, GET /api/orders/{id}, POST /api/orders/{id}/check; null for any other path
|
|
1197
|
+
if (answer) return answer;
|
|
1198
|
+
|
|
1199
|
+
// The cron (wrangler.jsonc "triggers": { "crons": ["*/10 * * * *"] }): finds a payment of a player who closed the page.
|
|
1200
|
+
async scheduled(_event, env, ctx) { ctx.waitUntil(createShop(env, opts).scan()); }
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
`createShop` reads the starter's settings: `RECEIVE_ADDRESS` (the creator's address), `TEST_MODE`, `NODE_API_KEY` (a secret, optional),
|
|
1204
|
+
`NANO_NODES` (optional), `TURNSTILE_SITE_KEY` and the secret `TURNSTILE_SECRET` (both, or no person check). `"none"`, `"skip"`, `"off"` and
|
|
1205
|
+
`"-"` mean "not set". It also gives `shop.owned(player)` (for the game's `/api/me`), `shop.config()` (`{ testMode, turnstileSiteKey }`, for
|
|
1206
|
+
the game's `/api/config`), `shop.status()` (the address, test mode, the nodes and the warnings, for the admin's Status card) and `shop.scan()`
|
|
1207
|
+
(the admin's "Check payments now": `createAdminHandler({ ..., check: (at) => shop.scan(at) })`).
|
|
1208
|
+
|
|
1209
|
+
On any other server, use the handler and a store:
|
|
1210
|
+
|
|
1211
|
+
```ts
|
|
1212
|
+
import { createShopHandler } from "@nano133/nanoterra/shop";
|
|
1213
|
+
import { createFirestoreShop } from "@nano133/nanoterra/shop/firestore";
|
|
1214
|
+
|
|
1215
|
+
const shop = createShopHandler({
|
|
1216
|
+
store: createFirestoreShop(getFirestore()), // or createD1Shop(env.DB), or createMemoryShop() for a test
|
|
1217
|
+
catalogue: looks, // looks.json as it was read: checked with checkLooks (a bad file throws here)
|
|
1218
|
+
payee: process.env.RECEIVE_ADDRESS, // the creator's address: payments go straight there
|
|
1219
|
+
testMode: process.env.TEST_MODE === "on",
|
|
1220
|
+
player: async (req) => signedInPlayerId(req), // YOUR player's sign-in (never the admin's): an id, or null
|
|
1221
|
+
nodes: nodesFor({ NODE_API_KEY: process.env.NODE_API_KEY }), // optional: the public nodes by default
|
|
1222
|
+
personCheck: async (token, req) => myTurnstileCheck(token, req), // optional
|
|
1223
|
+
});
|
|
1224
|
+
```
|
|
1225
|
+
|
|
1226
|
+
### The routes and the answers
|
|
1227
|
+
|
|
1228
|
+
The starter's paths and JSON, so the starter's page keeps working. `base` is `"/api/orders"`.
|
|
1229
|
+
|
|
1230
|
+
- `POST {base}` `{ lookId, turnstile? }`: a new order. 201 and the **pay view**; 200 and the same open order again (one waiting order per look).
|
|
1231
|
+
The browser sends a look's id and nothing else: **the server decides every price**.
|
|
1232
|
+
- `GET {base}/{id}`: the pay view of the player's own order. Another player's order looks like no order at all (404).
|
|
1233
|
+
- `POST {base}/{id}/check`: a payment scan first (at most one for the whole game every 4 s), then the pay view.
|
|
1234
|
+
- The pay view: `{ id, lookId, usd, amount, status, createdAt, expiresAt, xno, test, prices }`, and **only outside test mode** `address` and
|
|
1235
|
+
`uri` (`nano:<address>?amount=<raw>`, every digit). `status` is `"open"`, `"paid"`, `"expired"` or `"paid_late"`.
|
|
1236
|
+
- Refusals are `{ error }` with the starter's words: 401 "Sign in first.", 403 (a POST from another page, or a failed person check), 400
|
|
1237
|
+
(no such look, a free look, a body that is not readable), 409 "You already own this look.", 429 (10 new orders in 10 minutes for one
|
|
1238
|
+
visitor; three waiting orders for one player), 503 (no believable price, or every amount of the price held), a plain 500 for a fault
|
|
1239
|
+
(`onError` hears it; the player sees nothing of it).
|
|
1240
|
+
|
|
1241
|
+
### The money rules (every store keeps them: `shopStoreConformance` proves it)
|
|
1242
|
+
|
|
1243
|
+
- An order holds its amount from its start to **an hour after** it ends (15 minutes): **no two held orders ever hold the same amount**, even
|
|
1244
|
+
when they are made at the same instant. A late payment is then still recognised, and never counts for a newer order.
|
|
1245
|
+
- Amounts are text and BigInt, never floats. Prices round **up** (the creator never gets less than the price). At most a tenth of the price
|
|
1246
|
+
more (and at most 100 amounts); then the player is asked to try again in a minute.
|
|
1247
|
+
- A payment is ONE step: the order becomes `"paid"` and the look goes to **the order's player** (once), or `"paid_late"` (sent more than 2
|
|
1248
|
+
minutes after the order ended: no look; the creator sends it back by hand from their wallet: the server has no key). The hash is recorded:
|
|
1249
|
+
**one payment pays one order, once**, and a paid order is never paid twice. An order whose hold has ended can never be paid.
|
|
1250
|
+
- The scan **fails closed**: when no node answers, nothing is credited and the next scan tries again.
|
|
1251
|
+
- The price is the median of CoinGecko, Kraken and KuCoin (no keys), kept 5 minutes; the last good one for up to 6 hours when every feed
|
|
1252
|
+
fails; then nothing is sold. The pay sheet says "Powered by CoinGecko" (CoinGecko's terms).
|
|
1253
|
+
|
|
1254
|
+
### Test mode
|
|
1255
|
+
|
|
1256
|
+
**On until the owner's own address is set AND test mode is off.** With no payee, the example address
|
|
1257
|
+
(`nano_1111…hifc8npp`), an address with a wrong checksum (`validAddress` checks it) or `testMode: true`, the pay view carries **no address
|
|
1258
|
+
and no link**, and the pay sheet shows **"TEST MODE: DO NOT PAY"** where the QR code would be, with the wallet key off. The sheet decides it
|
|
1259
|
+
from the order, never from a setting of the page. A payment to an address that nobody holds the key for is lost for good.
|
|
1260
|
+
|
|
1261
|
+
### The stores
|
|
1262
|
+
|
|
1263
|
+
- **D1** (`createD1Shop(env.DB)`): the starter's tables `orders`, `owned` and `meta` (the keys `rate`, `scan`; `salt` for `visitorKey`), column
|
|
1264
|
+
for column. `@nano133/nanoterra/shop/d1.sql` creates them with `IF NOT EXISTS` (and `hits` for `d1Limit`): a new game adds it to its
|
|
1265
|
+
migrations; **a game made from the starter needs no migration** (its `migrations/0001_init.sql` made them). The names can be changed
|
|
1266
|
+
(`{ tables: { orders, owned, meta } }`), but the admin's D1 store reads the starter's names. The rules are in the statements:
|
|
1267
|
+
`amount_lock` and `hash` are UNIQUE, a payment and its grant are one batch, the scan's claim is one upsert.
|
|
1268
|
+
- **Firestore** (`createFirestoreShop(db, { orders, owned, meta, amounts, hashes })`): `orders/{id}`, `owned/{player__lookId}` and `meta/rate`
|
|
1269
|
+
are what the admin's Firestore store reads; `shop_amounts/{amount}` holds each held amount and `shop_hashes/{hash}` each recorded payment, so a
|
|
1270
|
+
transaction can keep the two unique rules. The indexes Firestore makes by itself are enough.
|
|
1271
|
+
- **Your own database:** write a `ShopStore` and run the rules on it (each case is short; give each its own test, with `SHOP_CASE_TIMEOUT_MS`):
|
|
1272
|
+
|
|
1273
|
+
```ts
|
|
1274
|
+
import { shopStoreConformance, SHOP_CASE_TIMEOUT_MS } from "@nano133/nanoterra/shop/conformance";
|
|
1275
|
+
for (const c of shopStoreConformance(async () => ({ store: newEmptyStore(), dump: () => allRowsAsText() }))) it(c.name, c.run, SHOP_CASE_TIMEOUT_MS);
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
**"Delete my data":** `store.forget(player, now)`. While an order of the player may still be paid (its amount is held) it deletes nothing and
|
|
1279
|
+
answers `waitUntil` (try again then). Else the owned looks and the orders that were never paid go; a paid order stays as the creator's sales
|
|
1280
|
+
record with the player (and its payer) replaced by `"deleted"`, and its payment's hash kept (so it never counts again); a **late** payment keeps
|
|
1281
|
+
its payer, because the creator must send it back.
|
|
1282
|
+
|
|
1283
|
+
### The panel
|
|
1284
|
+
|
|
1285
|
+
```ts
|
|
1286
|
+
import { mountShop } from "@nano133/nanoterra/shop/panel";
|
|
1287
|
+
import "@nano133/nanoterra/shop/style.css";
|
|
1288
|
+
|
|
1289
|
+
const shop = mountShop(document.getElementById("shop")!, {
|
|
1290
|
+
looks, // looks.json: the list (the server still decides every price)
|
|
1291
|
+
owned: () => me.owned, // the look ids the player owns (your /api/me: shop.owned(player))
|
|
1292
|
+
signedIn: () => Boolean(me.address),
|
|
1293
|
+
signIn: () => mySignIn(), // YOUR sign-in sheet: true when a player is signed in after it. The shop draws no sign-in.
|
|
1294
|
+
worn: (look) => wornIn(look.wear ?? "outfit") === look.id,
|
|
1295
|
+
wear: (look) => wearIt(look),
|
|
1296
|
+
takeOff: (slot) => takeOffIt(slot), // optional: each item tab starts with its plain choice
|
|
1297
|
+
onOwned: (lookId, look) => { me.owned.push(lookId); wearIt(look); }, // a paid order gave this look
|
|
1298
|
+
personCheck: (box) => turnstileToken(box), // optional: Turnstile drawn in `box`
|
|
1299
|
+
slots: { shovel: { tab: "Shovels", plain: "Plain shovel" } }, // optional words for the tabs
|
|
1300
|
+
});
|
|
1301
|
+
// key: false draws no Looks key: open it from your own key with shop.open(), or shop.buy("mint-cap") from your own Buy.
|
|
1302
|
+
```
|
|
1303
|
+
|
|
1304
|
+
- The looks sheet: a tab for each wear slot (the outfits first) when the game sells items, each look with its price before any step; Wear,
|
|
1305
|
+
Wearing, or Buy. `picture(look)` gives the game's own picture instead of the swatch.
|
|
1306
|
+
- Buy: the game's sign-in (`signIn`) when nobody is signed in, then the person check, then the pay sheet: the amount, the QR code of the exact
|
|
1307
|
+
`nano:` link (drawn by the package as SVG: no QR library, no outside service), "Open wallet", the address and the amount by hand, the time
|
|
1308
|
+
left, and a check every 5 seconds for 2 minutes ("Check again" after). Paid: "Paid. Thank you!" and `onOwned`. Paid late or expired: the
|
|
1309
|
+
starter's words.
|
|
1310
|
+
- Phone first: every key 44 px or more (the buy and pay keys 48 px). On a phone held upright the sheet rises from the bottom; on a wide screen
|
|
1311
|
+
it stands in the middle; on a phone held sideways the QR code stands alone on the left and the wallet key stays in view.
|
|
1312
|
+
- Light and dark: `theme: "light" | "dark" | "device"` (the default: the page's `data-n133-theme`, else the device's setting). Every colour is a
|
|
1313
|
+
`--n133-*` variable with a `--n133-dark-*` twin (and `--n133-sun` for Buy, `--n133-warn` for the stamp). The QR code stays dark on white.
|
|
1314
|
+
|
|
1315
|
+
### The page's Content Security Policy
|
|
1316
|
+
|
|
1317
|
+
The shop needs no other origin, except Turnstile when the game turns it on. Merge its lists into the game's own policy; never replace the game's:
|
|
1318
|
+
|
|
1319
|
+
```ts
|
|
1320
|
+
import { SHOP_CSP, mergeCsp } from "@nano133/nanoterra/shop";
|
|
1321
|
+
const CONTENT_SECURITY_POLICY = mergeCsp(MY_POLICY, SHOP_CSP); // adds Turnstile to script-src and frame-src; keeps every source of yours
|
|
1322
|
+
```
|
|
1323
|
+
|
|
1324
|
+
### Moving the game starter onto the package
|
|
1325
|
+
|
|
1326
|
+
The tables, the routes and the answers are the starter's, so the switch keeps **every order, owned look, price, scan time and limit
|
|
1327
|
+
counter** as they are, and the page needs no change for the server part.
|
|
1328
|
+
|
|
1329
|
+
1. `npm install @nano133/nanoterra@<this release>`. No migration: the starter's `0001_init.sql` already made `orders`, `owned`, `meta` and
|
|
1330
|
+
`hits` (the shop's only tables; 0002 to 0007 hold sign-in, Nanoterra, admin and friends tables).
|
|
1331
|
+
2. `src/worker/index.ts`: after `const session = await sessionOf(db, req, now)`, make the shop and let it answer (it replaces `createOrder`, the
|
|
1332
|
+
two order routes and `payView`): `const shop = createShop(env, { catalogue: looks, player: async () => session?.address ?? null, now: () => now });`
|
|
1333
|
+
then `const answer = await shop.handle(req); if (answer) return answer;`. In `/api/me`, `owned` is `await shop.owned(session.address)`. In
|
|
1334
|
+
`/api/config`, `testMode` and `turnstileSiteKey` can come from `shop.config()` (the same rules). In `scheduled`, `shop.scan(now)` replaces `sweep`.
|
|
1335
|
+
Keep the router's own per-visitor memory limits, its `sameOrigin` check and `secure()` as they are.
|
|
1336
|
+
3. `src/worker/admin.ts`: `check: (at) => shop.scan(at)` and `nodes: nodesFor(env).map((n) => n.name)` (`nodesFor` from `@nano133/nanoterra/shop`).
|
|
1337
|
+
4. Delete `src/worker/{orders,match,sweep,price,nodes}.ts` and their tests (`orders`, `sweep`, `price`, `nodes`, `amount`): the package carries
|
|
1338
|
+
them and their tests. `turnstile.ts` and `ratelimit.ts` stay while the sign-in uses them (or use `turnstileCheck`, `d1Limit` and `visitorKey`
|
|
1339
|
+
from the package: the same rows). `src/shared/amount.ts` goes when nothing else imports it.
|
|
1340
|
+
5. The page: `mountShop` with `key: false` (the starter's own Looks keys call `shop.open()`), `signIn` opening the starter's sign-in sheet,
|
|
1341
|
+
`onOwned` reading `/api/me` again and wearing an outfit, `personCheck` with the starter's Turnstile. Delete `src/client/ui/{looks,pay}.ts`
|
|
1342
|
+
and the looks and pay rules of `style.css`; set the `--n133-*` variables from the starter's `:root`. `ui/qr.ts` (and `qrcode-generator`)
|
|
1343
|
+
stays while the sign-in sheet draws its QR code (or use `qrSvg` from `@nano133/nanoterra/shop/panel`).
|
|
1344
|
+
6. Keep the starter's route tests (`admin.test.ts` "test mode" and "never shows one player's order to another"): they pass unchanged.
|
|
1345
|
+
|
|
1346
|
+
### What the shop does not do
|
|
1347
|
+
|
|
1348
|
+
- **Sign a player in.** It takes the game's signed-in player (`player(request)`) and asks the game for a sign-in (`signIn()`); the kit's
|
|
1349
|
+
player sign-in (wallets and a passkey) is its own path.
|
|
1350
|
+
- **Send money back.** It holds no key: a late payment is listed (`"paid_late"`), and the creator sends it back by hand.
|
|
1351
|
+
- **Sell anything random**, or anything that changes a score, a speed or a rule: a look only changes how the character looks.
|
|
1352
|
+
|
|
1017
1353
|
## Moving from the three old packages
|
|
1018
1354
|
|
|
1019
1355
|
| Before | Now |
|
|
@@ -1035,7 +1371,7 @@ does not know an id drops it, and nothing breaks.
|
|
|
1035
1371
|
- 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).
|
|
1036
1372
|
- Shared geometry for many characters at once, and a react-three-fiber wrapper.
|
|
1037
1373
|
- `WebGPURenderer`: the ink is a GLSL `ShaderMaterial`, so use `WebGLRenderer`.
|
|
1038
|
-
-
|
|
1374
|
+
- 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.
|
|
1039
1375
|
|
|
1040
1376
|
## Build and test
|
|
1041
1377
|
|
package/ai/SPEC.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
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
|
-
looks ladder (`/looks`), held input that does not stick (`/input`), the friends list (`/friends`, with its panel), the options menu (`/options`)
|
|
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
7
|
|
|
7
8
|
## 0. What the owner decides (ask)
|
|
8
9
|
|
|
@@ -12,11 +13,16 @@ looks ladder (`/looks`), held input that does not stick (`/input`), the friends
|
|
|
12
13
|
in the game starter it is `"character": "nanoterra" | "own"` in `game.config.json` (nothing else reads it).
|
|
13
14
|
3. The quality: `"high"` (toon body and ink outline, 2 draws) or `"low"` (no outline, 1 draw, for slow phones).
|
|
14
15
|
4. How the game moves the character: speed in metres a second, gravity and jump. The package animates; it does not move the object.
|
|
15
|
-
5. Does the game sell looks? If yes, the game is the seller (
|
|
16
|
+
5. Does the game sell looks? If yes, the game is the seller; see step 7 (the looks file), step 12 (the player's sign-in) and step 13 (the shop). Ask for the creator's own Nano address (it
|
|
17
|
+
receives every payment), and whether the game runs on a Cloudflare Worker with D1 (the starter's way) or another server with Firestore or its own database.
|
|
16
18
|
6. The friends list (step 9): Nanoterra's (the default), the game's own, or none. It is the admin's `friends` setting; ask which one the game starts with.
|
|
17
19
|
7. The options menu (step 10): which settings the game can really honour (a game with no music offers no Music slider), and whether the game opens it from its own
|
|
18
20
|
pause menu or shows the kit's gear key.
|
|
19
21
|
|
|
22
|
+
8. The player's sign-in (step 12), for a game with a server: which ways (a passkey and the three wallet apps by default), the game's DOMAIN for good (a
|
|
23
|
+
passkey belongs to one domain), the game's frozen passkey SCHEME (the owner approves its texts: they can never change), and whether the sheet offers the
|
|
24
|
+
spare key (yes by default; "no" for a game for small children).
|
|
25
|
+
|
|
20
26
|
**Check:** the owner has named the server (yes or no), the source of the character and the quality.
|
|
21
27
|
|
|
22
28
|
## 1. Install
|
|
@@ -188,7 +194,62 @@ import "@nano133/nanoterra/emotes/style.css";
|
|
|
188
194
|
the middle; a hold on the key and a slide onto a place plays it; Edit refuses a ninth with "Your wheel is full. Take one off first."; the wheel holds after a
|
|
189
195
|
reload. A walk ends Dance; Clap goes on over the walk. On a keyboard: B opens it, 2 plays the second place, Escape closes it.
|
|
190
196
|
|
|
191
|
-
## 12.
|
|
197
|
+
## 12. The player's sign-in (a game with a server)
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
npm install @nano133/nanoterra @nano133/signin
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
1. **The scheme, frozen.** Write it once in one file of the game, with the game's domain in its texts:
|
|
204
|
+
`export const SCHEME = { input: "<domain> player v1", salt: "<domain>", info: "<domain> player seed v1", versionByte: 1 }`. Add a test with two fixed vectors:
|
|
205
|
+
`assert.deepEqual(await schemeVector(SCHEME, "<64 hex>"), { seed, address })` (run it once to read the values, write them in, never change them). Never edit
|
|
206
|
+
the scheme after the first player: a new scheme is a new `versionByte` with new texts. Never take a default from anywhere.
|
|
207
|
+
2. **The store.** D1: add `@nano133/nanoterra/player-signin/d1.sql` to the migrations (on the game starter it keeps every table and session and adds
|
|
208
|
+
`signin_once`) and `createD1PlayerSignin(env.DB)`. Firestore: `createFirestorePlayerSignin(db)` and a TTL policy on `deleteAt` for the three collections.
|
|
209
|
+
Another database: a store that passes `playerSigninStoreConformance`.
|
|
210
|
+
3. **The handler.** `createPlayerSignin({ store, siteAddress, limit, visitor, personCheck })`; call `handle(request)` first for every request under `/api/`
|
|
211
|
+
(null: not its route). Give `limit` a limiter that counts for the whole game (D1), and `visitor` a keyed hash of the IP address. A game whose admin signs in
|
|
212
|
+
on the same paths (the starter) passes `sharedPaths: true`. Set `domain` once the game has its own domain.
|
|
213
|
+
4. **The player.** Everything that needs the player reads `signin.player(request)` (the shop's and the friends' `player` hook): never an address from the
|
|
214
|
+
body, and never the admin's login. The game's code exchange makes its session with `signin.signIn(request, address, "nanoterra")`.
|
|
215
|
+
5. **"Delete my data"** calls `signin.forget(player)` with the other stores' `forget`. A cron calls `signin.clean()`.
|
|
216
|
+
6. **The sheet.** `mountPlayerSignin(document.body, { name, scheme: SCHEME, qr, personCheck, onSignedIn })` and the stylesheet; the account key and the
|
|
217
|
+
shop's "sign in first" call `open()`. On `*.workers.dev` the sheet offers no passkey (it would be lost at the move to the game's own
|
|
218
|
+
domain); give `mountAdmin` `playerPasskeys: true` so the admin says so. Do not write your own passkey or wallet code, and never store, log or send the seed.
|
|
219
|
+
|
|
220
|
+
**Check:** on the game's real https address, with a phone (iOS 18 or a recent Android): Make a passkey, see "Keep a spare key", sign in, reload: still
|
|
221
|
+
signed in; sign out, "Use my passkey": the same address. A desktop browser with no PRF shows the passkey last and its plain words. A wallet sign-in works only
|
|
222
|
+
on a public https address (a wallet cannot reach localhost). The tests hold the scheme's vectors.
|
|
223
|
+
|
|
224
|
+
## 13. The shop (only if the game sells looks)
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { createShop } from "@nano133/nanoterra/shop/worker"; // a Worker with D1; else createShopHandler from "@nano133/nanoterra/shop"
|
|
228
|
+
import { mountShop } from "@nano133/nanoterra/shop/panel";
|
|
229
|
+
import "@nano133/nanoterra/shop/style.css";
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- The money goes straight from the player's wallet to the creator's address. The server holds **no key**: never add a seed, a private key or a send. The
|
|
233
|
+
shop reads the ledger and matches a unique, clean amount exactly. Do not copy the starter's `orders`, `match`, `sweep`, `price` or `nodes` files: install them.
|
|
234
|
+
- D1: add `@nano133/nanoterra/shop/d1.sql` to the migrations (a game made from the starter has the tables already: no migration). Firestore:
|
|
235
|
+
`createFirestoreShop(db)`. Another database: write a `ShopStore` and pass `shopStoreConformance` (each case its own test, `SHOP_CASE_TIMEOUT_MS`).
|
|
236
|
+
- `player(req)` reads the game's own PLAYER sign-in (never the admin's). The shop draws no sign-in: give the panel `signIn()`, which opens the game's
|
|
237
|
+
sign-in and answers true when a player is signed in.
|
|
238
|
+
- Settings (a Worker): `RECEIVE_ADDRESS` (the creator's address), `TEST_MODE` (`"on"` until the owner says sell), `NODE_API_KEY` (a secret, optional),
|
|
239
|
+
`TURNSTILE_SITE_KEY` and the secret `TURNSTILE_SECRET` (optional, both). Secrets go in the platform's secret store, never in code or a log.
|
|
240
|
+
- **Test mode stays on until the owner's own address is set AND test mode is off.** In test mode the pay sheet says "TEST MODE: DO NOT PAY", with no QR
|
|
241
|
+
code, no address and the wallet key off. Never show a pay screen to an address nobody holds the key for without that stamp.
|
|
242
|
+
- Add the cron (`*/10 * * * *`) that calls `shop.scan()`, and give the admin `check: (at) => shop.scan(at)`.
|
|
243
|
+
- Mount the panel with `onOwned(lookId)` (the game adds the look and wears it); `key: false` and `open()` when the game has its own Looks key.
|
|
244
|
+
- Merge `SHOP_CSP` into the game's Content Security Policy with `mergeCsp`; never replace the game's policy.
|
|
245
|
+
- "Delete my data" calls `store.forget(player, now)`; with `waitUntil`, ask the player to try again then.
|
|
246
|
+
- Tests: the shop's money tests are the package's. The game's tests read the game's own looks file and test only the game's own routes and wiring.
|
|
247
|
+
|
|
248
|
+
**Check:** with `TEST_MODE` on, Buy shows the stamp and no QR code, and the answer of `POST /api/orders` holds no `address` and no `uri`. With the owner's
|
|
249
|
+
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
|
|
250
|
+
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.
|
|
251
|
+
|
|
252
|
+
## 14. Do not
|
|
192
253
|
|
|
193
254
|
- Do not edit the geometry, the skeleton or the toon and ink materials.
|
|
194
255
|
- 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
|
|
@@ -197,3 +258,4 @@ reload. A walk ends Dance; Clap goes on over the walk. On a keyboard: B opens it
|
|
|
197
258
|
thing gives `hideTail: true` and `hideWings: true`, so a tail or wings do not poke through it.
|
|
198
259
|
The game loads the model and frees it; the character never does.
|
|
199
260
|
- Do not use a look or a record for a game's rules, a score or a payment: it is display only.
|
|
261
|
+
- Do not give a look on anything but a paid order (`"paid"`), and never give one on a sign-in or a ticket alone.
|
|
@@ -19,6 +19,9 @@ export type MountOptions = {
|
|
|
19
19
|
repoHelp?: string;
|
|
20
20
|
/** What the Sign-ins card says about what the game keeps and for how long ("30 days"). Default: only the time, the rough country and a short browser name. */
|
|
21
21
|
loginsNote?: string;
|
|
22
|
+
/** The game offers its players a passkey (`@nano133/nanoterra/player-signin`): on a preview address (`*.workers.dev`) the banner says that passkeys turn
|
|
23
|
+
* on with the game's own domain. */
|
|
24
|
+
playerPasskeys?: boolean;
|
|
22
25
|
/** The `fetch` to use (default: the page's). */
|
|
23
26
|
fetch?: typeof fetch;
|
|
24
27
|
};
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// The page: a top bar (who is signed in, Live or Test mode, the light/dark theme key, Sign out), what is still off (one banner), four numbers (earned, open now, to send back, players;
|
|
5
5
|
// each opens its list), then five tabs: Overview, Cosmetics (the Shelf), Orders, Players and Settings (the Game card, the sign-ins, the facts). Every tab is
|
|
6
6
|
// made once; a new read of the summary fills them again and keeps the chosen tab, the Shelf's draft, a search and the Game card.
|
|
7
|
+
import { PASSKEY_PREVIEW_NOTE, previewHost } from "../../player-signin/host.js";
|
|
7
8
|
import { ApiError, createApi } from "./api.js";
|
|
8
9
|
import { copyKey, h, plural, shortAddress } from "./dom.js";
|
|
9
10
|
import { gameCard } from "./gameCard.js";
|
|
@@ -158,8 +159,9 @@ export function mountAdmin(root, options) {
|
|
|
158
159
|
update(s) {
|
|
159
160
|
who.replaceChildren(h("span", { class: "who-label" }, "Signed in as"), s.address ? h("span", { class: "who-address" }, h("code", { title: s.address }, shortAddress(s.address)), copyKey(s.address, "Copy your address", icon("copy", 14))) : h("span", {}, "the game's owner"));
|
|
160
161
|
chips.replaceChildren(h("span", { class: `chip ${s.testMode ? "chip-warn" : "chip-ok"}`, title: s.testMode ? "Payments are not real" : "Payments are real" }, h("span", { class: "chip-dot", "aria-hidden": "true" }), s.testMode ? "Test mode" : "Live"), s.turnstile === undefined ? "" : h("span", { class: `chip ${s.turnstile ? "chip-ok" : "chip-warn"}` }, h("span", { class: "chip-dot", "aria-hidden": "true" }), s.turnstile ? "Bot check on" : "Bot check off"));
|
|
161
|
-
|
|
162
|
-
banner.
|
|
162
|
+
const notes = options.playerPasskeys && previewHost(location.hostname) ? [...s.warnings, PASSKEY_PREVIEW_NOTE] : s.warnings;
|
|
163
|
+
banner.hidden = !notes.length;
|
|
164
|
+
banner.replaceChildren(h("div", { class: "banner-icon", "aria-hidden": "true" }, icon("alert", 20)), h("div", {}, h("h2", {}, "Check these"), h("ul", {}, ...notes.map((w) => h("li", {}, w)))));
|
|
163
165
|
const ownedLooks = s.players.reduce((n, p) => n + p.looks.length, 0);
|
|
164
166
|
const late = s.totals.paidLate.count;
|
|
165
167
|
kpis.replaceChildren(kpi("earned", "coin", "Earned", `${s.totals.paid.xno} XNO`, `$${s.totals.paid.usd} · ${plural(s.totals.paid.count, "order")}`, () => go("orders", "paid")), kpi("open", "clock", "Open now", String(s.counts.open), `${s.counts.expired} expired`, () => go("orders", "open")), kpi("late", "back", "To send back", `${s.totals.paidLate.xno} XNO`, late ? `$${s.totals.paidLate.usd} · ${plural(late, "order")}` : "Nothing to send back", () => go("orders", "paid_late"), late > 0), kpi("players", "players", "Players", String(s.players.length), `${plural(ownedLooks, "look")} owned`, () => go("players")));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { D1Like } from "../../admin/adapters/d1.js";
|
|
2
|
+
import { type PlayerSigninStore } 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 D1PlayerSigninTables = {
|
|
6
|
+
requests?: string;
|
|
7
|
+
sessions?: string;
|
|
8
|
+
once?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function createD1PlayerSignin(db: D1Like, tables?: D1PlayerSigninTables): PlayerSigninStore;
|