@nano133/nanoterra 0.1.0 → 0.2.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 +64 -0
- package/README.md +189 -2
- package/ai/SPEC.md +2 -1
- package/dist/admin/adapters/d1.d.ts +27 -0
- package/dist/admin/adapters/d1.js +73 -0
- package/dist/admin/adapters/firestore.d.ts +40 -0
- package/dist/admin/adapters/firestore.js +53 -0
- package/dist/admin/amount.d.ts +5 -0
- package/dist/admin/amount.js +11 -0
- package/dist/admin/conformance.d.ts +132 -0
- package/dist/admin/conformance.js +467 -0
- package/dist/admin/guard.d.ts +38 -0
- package/dist/admin/guard.js +57 -0
- package/dist/admin/handler.d.ts +63 -0
- package/dist/admin/handler.js +170 -0
- package/dist/admin/http.d.ts +17 -0
- package/dist/admin/http.js +76 -0
- package/dist/admin/index.d.ts +13 -0
- package/dist/admin/index.js +14 -0
- package/dist/admin/memory.d.ts +32 -0
- package/dist/admin/memory.js +40 -0
- package/dist/admin/panel/api.d.ts +11 -0
- package/dist/admin/panel/api.js +33 -0
- package/dist/admin/panel/dom.d.ts +7 -0
- package/dist/admin/panel/dom.js +33 -0
- package/dist/admin/panel/gameCard.d.ts +4 -0
- package/dist/admin/panel/gameCard.js +56 -0
- package/dist/admin/panel/index.d.ts +10 -0
- package/dist/admin/panel/index.js +13 -0
- package/dist/admin/panel/mount.d.ts +30 -0
- package/dist/admin/panel/mount.js +147 -0
- package/dist/admin/panel/shelf.d.ts +17 -0
- package/dist/admin/panel/shelf.js +247 -0
- package/dist/admin/panel/shelfData.d.ts +37 -0
- package/dist/admin/panel/shelfData.js +68 -0
- package/dist/admin/panel/signins.d.ts +6 -0
- package/dist/admin/panel/signins.js +69 -0
- package/dist/admin/store.d.ts +121 -0
- package/dist/admin/store.js +3 -0
- package/dist/admin/summary.d.ts +39 -0
- package/dist/admin/summary.js +85 -0
- package/dist/admin/switches.d.ts +58 -0
- package/dist/admin/switches.js +61 -0
- package/dist/looks/change.d.ts +8 -4
- package/dist/looks/change.js +25 -3
- package/dist/looks/index.d.ts +3 -3
- package/dist/looks/index.js +2 -2
- package/dist/looks/json.d.ts +2 -2
- package/dist/looks/json.js +6 -4
- package/dist/looks/look.d.ts +29 -3
- package/dist/looks/look.js +60 -4
- package/package.json +9 -2
- package/src/admin/adapters/d1.ts +94 -0
- package/src/admin/adapters/firestore.ts +78 -0
- package/src/admin/amount.ts +14 -0
- package/src/admin/conformance.ts +605 -0
- package/src/admin/guard.ts +86 -0
- package/src/admin/handler.ts +200 -0
- package/src/admin/http.ts +74 -0
- package/src/admin/index.ts +21 -0
- package/src/admin/memory.ts +59 -0
- package/src/admin/panel/api.ts +37 -0
- package/src/admin/panel/dom.ts +31 -0
- package/src/admin/panel/gameCard.ts +63 -0
- package/src/admin/panel/index.ts +18 -0
- package/src/admin/panel/mount.ts +227 -0
- package/src/admin/panel/shelf.ts +337 -0
- package/src/admin/panel/shelfData.ts +87 -0
- package/src/admin/panel/signins.ts +68 -0
- package/src/admin/panel/style.css +542 -0
- package/src/admin/store.ts +98 -0
- package/src/admin/summary.ts +103 -0
- package/src/admin/switches.ts +74 -0
- package/src/looks/change.ts +25 -6
- package/src/looks/index.ts +3 -3
- package/src/looks/json.ts +6 -4
- package/src/looks/look.ts +66 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (2026-10-27)
|
|
4
|
+
|
|
5
|
+
- **Slots** on a look (`@nano133/nanoterra/looks`, and the Shelf of `/admin/panel`): `slots?: Record<string, string>`, what a game defines for itself (a pair of wings, a trail colour). Names are 1 to 24
|
|
6
|
+
lowercase letters, digits or dashes starting with a letter; values are words (1 to 40 lowercase letters, digits or dashes) or `#rrggbb` colours; at most 8 on a look. `lookProblems` checks them
|
|
7
|
+
(`slots`, `slots.<name>`), `checkLooks` keeps them, `looksJson` writes them after `change`, `diffLooks` lists a change as the field `"slots"` (order and the case of a colour are not changes), and
|
|
8
|
+
`removalProblems` reports a changed slot on a look that a player owns (players own a look by its id; a price, a name, a colour or a hat of an owned look stays allowed). New: `sameSlots`,
|
|
9
|
+
`cleanSlots`, `slotValueOk`, `SLOTS_MAX`, `SLOT_NAME_PATTERN`, `SLOT_WORD_PATTERN`, the type `Slots`. A file from before slots reads and writes exactly as it did. The Shelf keeps the slots in its
|
|
10
|
+
draft and has a Slots editor. This is a minor change (0.2.0); a game on 0.1.0 drops slots, so it must move to the new version.
|
|
11
|
+
- **`@nano133/nanoterra/admin`** (step 1 of the admin panel): the admin of a game, in the package, with no Three.js, no database and no cookie. The game gives a session and a store.
|
|
12
|
+
- `adminAccess(input)`: the guard, ONE pure function. The same five checks as the game starter's `adminGuard`, in the same order (owner set, session present, wallet login,
|
|
13
|
+
same address, not older than one day). It only refuses MORE than the starter does: a bad owner address, a time that is not a number or lies more than five minutes in the
|
|
14
|
+
future, a login word that is not exactly "wallet", and a limit above one day are all refusals. A fault inside it is a refusal.
|
|
15
|
+
- `createAdminHandler(options)`: the routes (summary, the two game switches, the payment check) on Web `Request` and `Response`. Every path under the base goes through the
|
|
16
|
+
guard first, so a visitor learns nothing about the routes. A POST must come from the game's own page. No CORS header, always `no-store`, a body limit, a rate limit.
|
|
17
|
+
- `AdminStore`: what a game gives (read the two settings, save a checked patch, the summary). The only writes are the two `setting:` values.
|
|
18
|
+
- `loadGameSettings(store, defaults)`: the two switches for the game's own routes (the listing file, the character); the defaults when the store fails.
|
|
19
|
+
- `createMemoryStore()`: a store in memory, for tests.
|
|
20
|
+
- **`@nano133/nanoterra/admin/panel`** (step 2): `mountAdmin(element, options)`, the admin page in plain DOM with no framework: the Game card, the status warnings, the totals, the Shelf (the
|
|
21
|
+
starter's, moved here: it edits a draft and gives text to copy, and sends no request), the orders with "Paid late" and an optional "Check payments now", the looks that players own,
|
|
22
|
+
and the screens for a visitor who is signed out, a wrong wallet, a Nanoterra sign-in and a missing owner address. The game gives `signIn`, `signOut` and its `looks`.
|
|
23
|
+
`@nano133/nanoterra/admin/style.css`: its stylesheet, every rule under `.n133-admin`. The summary of the handler now says `canCheck`.
|
|
24
|
+
- **`@nano133/nanoterra/admin/d1`** and **`/admin/firestore`** (step 3): `createD1Store(db)` (the game starter's tables, with the starter's own queries) and `createFirestoreStore(db, names?)` (a small interface; no
|
|
25
|
+
Firebase dependency). Both write ONLY the two settings. `adminStoreConformance(make)` (in `/admin/conformance`) is the store contract that every adapter passes: the memory store, D1 on SQLite and Firestore on a fake.
|
|
26
|
+
The summary's numbers come from one file (`summarize`, `moneyOf`, `shapeOrders`), so every adapter says the same thing. The memory store now takes orders with a raw `amountRaw`, like a real database.
|
|
27
|
+
- **The Game card's words for sign-in Off** (Wes, "off means off"): "Sign-in from Nanoterra is Off, so every player plays as the island's first character, a player who signed in from Nanoterra before
|
|
28
|
+
included." The card only says it: the game's own page must draw no Nanoterra character while sign-in is Off (the game starter's `src/client/ui/player.ts` does).
|
|
29
|
+
- **Who signed in, and "Sign out everywhere"** (optional, Wes's security ideas): `AdminLogin` (time, country, short browser name) and the optional store calls `recentLogins(limit)` and
|
|
30
|
+
`signOutEverywhere()`. With them the summary has `logins` (the last 10, only those three things, whatever the store gives) and `canSignOutEverywhere`, the handler has `POST {base}/signout-all`
|
|
31
|
+
(guarded, same origin, 5 a minute, deletes the admin sessions and no player session), and the panel has a Sign-ins card with a two-press "Sign out everywhere". `createD1Store(db, { logins: true })`
|
|
32
|
+
reads `admin_logins` and deletes `sessions WHERE scope = 'admin'`. Memory store: `createMemoryStore({ logins: true })`. The store contract has cases for both. The panel option `loginsNote` says what
|
|
33
|
+
the game keeps and for how long (30 days suggested).
|
|
34
|
+
- **`@nano133/nanoterra/admin/conformance`**: `adminConformance(host)` gives test cases that a game runs against its OWN session code and store: every route refuses a visitor
|
|
35
|
+
who is signed out, a login made for the game's players (also its token in the admin cookie), a Nanoterra sign-in, a login made some way the game does not know, another wallet, a missing
|
|
36
|
+
owner address, an expired login and a login that is old but still valid in the store; OPTIONS and HEAD are refused with no preflight answer; other spellings of an admin path open nothing with
|
|
37
|
+
any method; the game's other routes under the base (`otherRoutes`) refuse a visitor; a POST from another site is refused; a body that is too big is refused; every answer is `no-store` with
|
|
38
|
+
no CORS header. (The first version of this suite passed four wirings that leak everything; a security read found them, and each is a test now.)
|
|
39
|
+
- **A fourth security read** (the same fresh agent as the third, 2026-10-27, on the third-read fixes) found that `adminSigninConformance` still passed wrong chains: one with no binding of a request to its
|
|
40
|
+
browser inside one scope, one whose cross-scope ask gave a login in the STORED scope, one whose `sign()` stopped landing (the checks then ran on an unsigned request), one that checked the scope only
|
|
41
|
+
while the request was fresh, one whose Sign out everywhere left a request that was still waiting for a signature, and one with no `signOutEverywhere` at all (the case was skipped). Fixed: `sign()` must
|
|
42
|
+
throw on a refusal; a cross-scope ask must give no login of either kind and the request must still sign in afterwards (the signature is proven to have landed); a same-scope ask from another browser gives
|
|
43
|
+
none; the host may give `advance(ms)` to repeat the asks after the admin's request life; `adminSigninConformance(host, { signOutEverywhere: true })` fails a host that has no call; the store contract
|
|
44
|
+
REQUIRES `seedRequests` and `pendingRequests` when the store has `signOutEverywhere`. `createD1Store(db, { logins: true })`'s `signOutEverywhere` also ends the pending admin requests
|
|
45
|
+
(`expires_at = 0`). Eight fake chains (one good, seven wrong) run against the contract.
|
|
46
|
+
A fifth read of these fixes (same agent) found four small gaps, fixed: `adminSigninConformance(host, { advance: true })` fails a host that cannot move its clock (the time case could be skipped);
|
|
47
|
+
the late asks are made at 10 and at 55 minutes (a scope check that holds for 30 minutes passed); a collected request must give another browser no login either; and the kit's `dist` must be
|
|
48
|
+
built before `npm pack` (a packed tarball of an unbuilt change tested the old code). Ten fake chains (one good, nine wrong).
|
|
49
|
+
- **A third security read** (another new fresh agent, 2026-10-27) found that the suite still passed four wirings that leak: a game that caches 200 answers by URL, one that keeps the sessions it has read
|
|
50
|
+
in memory (a login that sign-out or Sign out everywhere ended still opens), one that also reads a player's token from a Bearer header, and one that reads it from the query. Fixed: the suite sends every
|
|
51
|
+
refused visitor again right after the owner was served; `host.endLogin` and the login that used Sign out everywhere are used again and must be refused; `req.tokenAt` sends a player's token in other
|
|
52
|
+
places than the cookie; and `adminSigninConformance(host)` is a contract for the code that MAKES the logins (a game request never makes an admin login and the other way round, for the browser that
|
|
53
|
+
started it, another browser and retyped credentials; once; and a signed admin request makes no login after Sign out everywhere). `createD1Store(db, { logins: true })`'s `signOutEverywhere` also marks
|
|
54
|
+
the admin sign-in requests that were not delivered as delivered (needs `signin_requests.scope` and `.delivered`). The list shows a browser name only when it is words. Four tests run the four wirings
|
|
55
|
+
against the suite, and three fake sign-in chains run against the contract (one good, three wrong).
|
|
56
|
+
- **A second security read** (new fresh agent, 2026-10-27) found that the second conformance suite still passed some wirings that leak or act. Fixed: the suite sends a foreign Origin (another site,
|
|
57
|
+
`"null"`, a sibling subdomain) and Sec-Fetch-Site cross-site and same-site on every route with every method, for the owner too; it sends an old login that is still valid THREE times (the last two
|
|
58
|
+
with `req.again`) so a rolling session fails; `expired_login` is now made less than a day ago so only the game's own store can refuse it; new kind `signed_out_login`; the spelling tricks run on
|
|
59
|
+
every route and method; the game's other routes must refuse with a refusal that carries no data. **The handler now refuses a read that says it comes from another page**, as it refused a write.
|
|
60
|
+
Looks: `wholeCents` is exact (`0.300000001` and `1e-9` no longer pass); a slot colour is written in lower case (a change of only its case is no change); the names `constructor` and
|
|
61
|
+
`prototype` are reserved; `checkLooks` checks and copies one snapshot; the Shelf never gives an id that a player owns to a new look.
|
|
62
|
+
- **Fixes from a security read of the first admin code:** odd spellings of the base path (a bad percent code, backslashes, many levels of encoding) now reach the guard instead of falling through;
|
|
63
|
+
a fault in the game's `now()` or `onError()` can no longer reject `handle()` with the raw error; the default limiter is keyed by base path and owner, so two games in one process do not share a
|
|
64
|
+
count; the message for an AI assistant takes only valid look ids, written as JSON strings (an id from a database cannot write a line into it); the summary counts only the four statuses it knows
|
|
65
|
+
and reads a dollar text only when it is plain dollars.
|
|
66
|
+
|
|
3
67
|
## 0.1.0
|
|
4
68
|
|
|
5
69
|
First version (Pre-alpha). **One package for Nanoterra.** It replaces `@nano133/character` 0.1.0, `@nano133/ticket` 0.1.0 and
|
package/README.md
CHANGED
|
@@ -61,6 +61,10 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
|
|
|
61
61
|
| `@nano133/nanoterra/ticket` | read and check a sign-in ticket | no | 4.7 kB (all), 3.1 kB (`readTicket` alone) |
|
|
62
62
|
| `@nano133/nanoterra/looks` | the looks ladder | no | 1.9 kB (all), 0.9 kB (`checkLooks` alone) |
|
|
63
63
|
| `@nano133/nanoterra/character` | the builder and `createCharacter` (it includes the record) | yes | 20.4 kB (all), 17.8 kB (`createCharacter` alone) |
|
|
64
|
+
| `@nano133/nanoterra/admin` | the admin of a game: the guard, the handler, the store type (server code; since 0.2.0) | no | 3.7 kB |
|
|
65
|
+
| `@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 |
|
|
66
|
+
| `@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 | 8.5 kB + 2.4 kB of CSS |
|
|
67
|
+
| `@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 |
|
|
64
68
|
|
|
65
69
|
Measured 2026-10-26: `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
|
|
66
70
|
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.
|
|
@@ -169,7 +173,7 @@ a player's choice of body never changes a rule. nano133.com's island and room do
|
|
|
169
173
|
|
|
170
174
|
## Worn items (the next version)
|
|
171
175
|
|
|
172
|
-
**
|
|
176
|
+
**Versions 0.1.0 and 0.2.0 draw the body only**, as `@nano133/character` 0.1.0 did. The island's 14 shop items (hats, scarves, packs, tops, a buddy, a blade) are
|
|
173
177
|
fitted to every base by nano133.com's own code, and are not in this package yet. The builder takes them through **one small interface**,
|
|
174
178
|
`WornAdapter` (what it needs of a worn thing: its shapes and bones through `add(ctx)`, what it does to the hair, `hidesBadge`, `hideTail`), so a game
|
|
175
179
|
may bring its own, and the island brings its adapter the same way.
|
|
@@ -296,9 +300,36 @@ type Look = {
|
|
|
296
300
|
name: string; // 1 to 40 characters
|
|
297
301
|
priceUsd: number; // US dollars, at most 2 decimals; 0 is the free look
|
|
298
302
|
change: { body: string; hat?: "cap" | "beanie" | "crown" | "tophat"; hatColor?: string }; // colours are "#rrggbb"
|
|
303
|
+
slots?: Record<string, string>; // what YOUR game defines for itself: see "Slots" below. Optional.
|
|
299
304
|
};
|
|
300
305
|
```
|
|
301
306
|
|
|
307
|
+
### Slots: what your game defines on a look
|
|
308
|
+
|
|
309
|
+
A game with its own parts (a pair of wings, a trail colour) needs a place for them on a look. That place is `slots`, an optional object of names and values:
|
|
310
|
+
|
|
311
|
+
```json
|
|
312
|
+
{ "id": "ember-wings", "name": "Ember wings", "priceUsd": 0.25, "change": { "body": "#ff7a3d" }, "slots": { "wings": "ember", "trail": "#ffcf5a" } }
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
- A **name** is 1 to 24 lowercase letters, digits or dashes, starting with a letter (`SLOT_NAME_PATTERN`).
|
|
316
|
+
- A **value** is a **word** (1 to 40 lowercase letters, digits or dashes, `SLOT_WORD_PATTERN`) or a **colour** (`#rrggbb`). Nothing else: no number, no list, no nested object. A game that needs
|
|
317
|
+
a number writes it as a word it understands (`"size": "large"`).
|
|
318
|
+
- **At most 8 slots** on a look (`SLOTS_MAX`), so at most 512 characters of names and values. The names `constructor` and `prototype` are reserved (`RESERVED_SLOT_NAMES`).
|
|
319
|
+
- **A colour is written in lower case** by `checkLooks`, `cleanSlots` and `looksJson` (`#FFCF5A` becomes `#ffcf5a`), so the game reads one text for one colour and a change of only the case is no change.
|
|
320
|
+
- A look with no `slots`, or with an empty one, is **the same look**. A file from before slots reads and writes exactly as it did. Any other unknown field of a look is still dropped by `checkLooks`.
|
|
321
|
+
- Your game reads `look.slots` from the checked looks; the package gives them no meaning.
|
|
322
|
+
- `lookProblems` names a bad slot with the look, the field (`slots` or `slots.wings`) and plain words. `checkLooks` keeps the slots (a clean copy). `looksJson` writes them after `change`, on the
|
|
323
|
+
look's own line, in your order of names. `diffLooks` lists a change as the field `"slots"` (the same names and values in any order are not a change; a colour in upper or lower case is the same).
|
|
324
|
+
- **Players own a look by its id, so the slots of a look that a player owns must not change:** the game reads the slots of the id, and a new slot would change what they own. `removalProblems`
|
|
325
|
+
reports a changed slot on an owned look as a mistake with the field `"slots"` ("Add a new look with the new slots"), as it reports a removed owned id. A price, a name, a body colour or a hat of an
|
|
326
|
+
owned look may still change. The free look is every player's and no order owns it, so its slots may change; a look that nobody owns may change its slots too. The Shelf never gives an id that a
|
|
327
|
+
player owns to a NEW look (when an owned look is no longer in the file, a new look named like it gets the next free id, such as `mint-cap-2`).
|
|
328
|
+
- The Shelf (`/admin/panel`) keeps the slots in its draft, shows them on the look's card, and has a "Slots" editor on each look: rows of a name and a value, Add and Remove, with the count of 8.
|
|
329
|
+
A name used twice, a bad name and a bad value are shown before anything can be copied.
|
|
330
|
+
|
|
331
|
+
A game on version 0.1.0 of this package drops `slots` when it checks a file: use the version that has them (a minor change, 0.2.0) in the game, the server and the Shelf.
|
|
332
|
+
|
|
302
333
|
The rules (the same as the starter's `AGENTS.md`):
|
|
303
334
|
|
|
304
335
|
- A look only changes how the character looks. **It never changes a score, a speed or a rule.**
|
|
@@ -310,12 +341,13 @@ The rules (the same as the starter's `AGENTS.md`):
|
|
|
310
341
|
|---|---|
|
|
311
342
|
| `lookProblems(raw)` | Every mistake in a list of looks, as `{ look, field, message }`. An empty array is a good list. |
|
|
312
343
|
| `checkLooks(raw)` | The looks, or an `Error` with the first mistake in plain words. |
|
|
313
|
-
| `removalProblems(before, after, owned)` | The looks in `before` that a player owns (`owned` is a set of ids) and that `after` leaves out. |
|
|
344
|
+
| `removalProblems(before, after, owned)` | The looks in `before` that a player owns (`owned` is a set of ids) and that `after` leaves out, or whose slots `after` changes. |
|
|
314
345
|
| `diffLooks(before, after)` | `{ added, removed, changed }` by id. A new order is not a change. |
|
|
315
346
|
| `looksJson(looks)` | The text of `looks.json`, one look on each line, as the starter writes it. A change of one look is a change of one line. |
|
|
316
347
|
| `slugId(name, taken)` | A free id for a name: `"Mint Cap"` gives `mint-cap`, then `mint-cap-2`. |
|
|
317
348
|
| `PRICE_LADDER`, `rungOf(usd)`, `priceText(usd)` | The six steps nano133.com uses, from 1¢ to $1, and a price as a page writes it (`25¢`, `$1.10`). |
|
|
318
349
|
| `usdText(usd)`, `wholeCents(usd)`, `HATS` | `"$0.25"`; whether a price has no fraction of a cent; the hats. |
|
|
350
|
+
| `sameSlots(a, b)`, `cleanSlots(slots)`, `slotValueOk(v)`, `SLOTS_MAX` | Whether two looks have the same slots; a clean copy (none when empty); whether a value is a word or a colour; 8. |
|
|
319
351
|
|
|
320
352
|
| Step | Price | For |
|
|
321
353
|
|---|---|---|
|
|
@@ -329,6 +361,161 @@ The rules (the same as the starter's `AGENTS.md`):
|
|
|
329
361
|
The ladder is a suggestion. Any price with at most 2 decimals, up to $1000, is good. `wholeCents` accepts every price in whole cents (the starter's old check,
|
|
330
362
|
`Math.round(x * 100) === x * 100`, refused $0.07, $0.29, $0.57 and $1.10 because of floating point).
|
|
331
363
|
|
|
364
|
+
## The admin (`/admin`; since 0.2.0)
|
|
365
|
+
|
|
366
|
+
The admin of a game that sells Nanoterra looks for Nano, in the package: one panel for the game, its looks and its orders. It has two parts that never load each other:
|
|
367
|
+
the **server part** (`/admin`: the guard, the routes and a test suite) and the **panel** (`/admin/panel`: the page, drawn in the browser). Neither has Three.js. The package has no
|
|
368
|
+
database and reads no cookie: **the game gives a session and a store**.
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
import { createAdminHandler } from "@nano133/nanoterra/admin";
|
|
372
|
+
|
|
373
|
+
const admin = createAdminHandler({
|
|
374
|
+
store, // your database: readSettings(), saveSettings(patch, now), summary(now) (a memory store is in the package, for tests)
|
|
375
|
+
defaults: { character: "nanoterra", signin: "exchange" }, // what the two switches are until /admin sets them (your game.config.json)
|
|
376
|
+
owner: ownerAddress, // "nano_..." lowercase, its checksum checked by you; null when none is set
|
|
377
|
+
session: (req, now) => readAdminSession(req, now), // YOUR admin login: its own cookie; return { address, createdAt, via: "wallet" } or null
|
|
378
|
+
status: (now, summary) => ({ address: ownerAddress, testMode, warnings }), // what only your game knows (the store's numbers come too, for a warning like "2 payments came late")
|
|
379
|
+
lookName: (id) => looks.find((l) => l.id === id)?.name,
|
|
380
|
+
check: (now) => checkPayments(now), // optional: the payment check
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const answer = await admin.handle(request); // null when the request is not for /api/admin
|
|
384
|
+
if (answer) return answer;
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
**The guard** is `adminAccess({ owner, session, now, maxAgeMs? })`, one pure function with the same five checks as the game starter's `adminGuard`, in the same order: the
|
|
388
|
+
game has a real owner address; there is a login; it was made with a wallet signature (a sign-in from Nanoterra names an address but proves no key, so it never opens the
|
|
389
|
+
admin); it is the owner's address; it is not more than a day old. **It only refuses more than the starter does, never less**: a bad owner, a time that is not a number
|
|
390
|
+
or lies more than five minutes in the future, a login word that is not exactly `"wallet"` and a limit that is not a real number are refusals, a limit above one day is cut
|
|
391
|
+
to one day, and a fault inside the guard is a refusal. A test runs the starter's guard and this one over 10,000 inputs and proves that this one never allows what the
|
|
392
|
+
starter refuses.
|
|
393
|
+
|
|
394
|
+
**The handler** refuses a request that says it comes from another page, for a read as for a write: an `Origin` that is not the game's (also `"null"`), or `Sec-Fetch-Site` `cross-site` or `same-site`
|
|
395
|
+
(a sibling subdomain has the game's cookies). The admin API is called only by its own page, so whatever CORS headers a game's router might add, the answer is not for another page. A tool with
|
|
396
|
+
neither header (curl) and the browser's own address bar (`Sec-Fetch-Site: none`) are not foreign.
|
|
397
|
+
|
|
398
|
+
**The handler** sends every request under the base path (`/api/admin` by default) through the guard first, before the route is looked up: a visitor who is not the owner
|
|
399
|
+
gets 401 or 403 for any path, so nothing about the routes leaks. A POST must come from the game's own page (`Origin`, else `Sec-Fetch-Site: same-origin`). It sets no CORS
|
|
400
|
+
header, always sends `Cache-Control: no-store`, reads at most 512 bytes of a press, and limits presses to 30 a minute. A fault in your store gives a plain 500 that shows
|
|
401
|
+
nothing of the fault (`onError` hears it). The only writes are the two settings (and, when the game turns on the sign-in list, the deletion of the admin sessions), **never a look, a price or an order**: a change of looks stays a change of your repository.
|
|
402
|
+
|
|
403
|
+
**Your own routes read the switches** with `loadGameSettings(store, defaults)` (for the listing file's `signin` word and for the character): the stored word, else your default,
|
|
404
|
+
and your defaults when the store fails.
|
|
405
|
+
|
|
406
|
+
**The conformance suite** proves your whole chain, not only the guard: the cookie you read, the session lookup, the owner you pass.
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
import { adminConformance } from "@nano133/nanoterra/admin/conformance";
|
|
410
|
+
|
|
411
|
+
const host = { routes: admin.routes, basePath: admin.basePath, origin: "https://game.example", request: (who, req) => sendAs(who, req) };
|
|
412
|
+
for (const c of adminConformance(host)) it(c.name, c.run); // vitest, node:test or any runner
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
`sendAs(who, req)` makes a request as one of eleven visitors from YOUR sessions: `signed_out`; `game_login` (a login made for your players, which must NOT open the admin);
|
|
416
|
+
`game_token_as_admin` (the token of that player login put in the ADMIN cookie: your store must keep the two scopes apart, not only the cookie names); `nanoterra_login`;
|
|
417
|
+
`unknown_via_login` (an admin login whose `via` is a word you do not know, like `"ticket"`: it must fail closed); `other_wallet`; `no_address`;
|
|
418
|
+
`expired_login` (made LESS than a day ago and expired by your own store, so only your store can refuse it: a game that ignores its own expiry fails); `signed_out_login` (a login that your own
|
|
419
|
+
sign-out ended); `old_valid_row` (an admin login more than a day old whose stored row is still valid, so only the guard's age check can refuse it); and `owner`. **Support `req.again`:** when it
|
|
420
|
+
is true, send the SAME login as the last request of that kind and do not make a new one. The suite sends `old_valid_row` three times, the last two with `again`, and requires 401 each time: a
|
|
421
|
+
session that renews its creation time on every read is a rolling session, and a stolen cookie would then live for ever.
|
|
422
|
+
The suite checks every route in `admin.routes` for each, and also: an unknown path gets the same refusal; **OPTIONS and HEAD** on every route are refused like any other method and no
|
|
423
|
+
preflight is ever answered; other spellings of EVERY admin path (upper case, a doubled slash, a percent code, a backslash, a dot segment) open nothing with any method; **a request from another
|
|
424
|
+
page** (a foreign Origin, `"null"`, a sibling subdomain, Sec-Fetch-Site cross-site or same-site) is refused on every route with every method, for the owner too, and never gets a CORS header; a POST
|
|
425
|
+
from another site is refused; a method that a route does not have is refused; a body that is too big is refused; and **every answer is `no-store` with no CORS header**, for every method and
|
|
426
|
+
status. A route that someone adds later cannot skip the guard, because the suite walks the route list.
|
|
427
|
+
|
|
428
|
+
**Three more things a host should support**, so that the suite can catch the wirings that a security read invented against it: `host.endLogin(who)` (end the login of the last request of that kind with
|
|
429
|
+
your REAL sign-out route: the suite then sends the same login again and requires a refusal, so a game that keeps sessions in a cache fails); `req.tokenAt` (send a player's login token as a Bearer
|
|
430
|
+
header, in the query or in another header instead of the cookie: a game that also reads a player's token from there fails); and the suite sends every refused visitor again right after the owner was
|
|
431
|
+
served on the same URL (a game that caches answers by URL fails) and uses the login that did "Sign out everywhere" again (it must be refused at once).
|
|
432
|
+
|
|
433
|
+
**The sign-in chain contract** (`adminSigninConformance(host, { signOutEverywhere })`, in the same path) tests the code that MAKES the logins, which the suite above cannot see. Give it a host that drives your own sign-in:
|
|
434
|
+
`start(scope)` (a new browser starts a request for the game or for the admin), `sign(request)` (the owner's wallet signs it; it MUST throw when the signature is refused, so a signature that stops working
|
|
435
|
+
cannot make the contract pass), `finish(request, as, credentials)` (a browser asks to finish it as a scope with its own credentials, as another browser, or with its credentials "retyped" for the other
|
|
436
|
+
scope: a cookie copied to the other scope's name, a value that the other scope's hashing would match), optionally `advance(ms)` (moves the game's clock; the cross-scope asks then repeat 10 and 55 minutes after the start, after the admin's
|
|
437
|
+
request life has ended and inside the game's hour of delivery) and optionally `signOutEverywhere()`. It requires: a request started for the game never makes an admin login, a request started for the admin never makes a game login (a cross-scope
|
|
438
|
+
ask gives NO login of either kind, and the request still signs in its own browser afterwards), each signs in once in its own scope (a request that was collected gives no other browser a login either), a request signs in only the browser that started it (also inside the same
|
|
439
|
+
scope), and Sign out everywhere ends the admin's pending requests, those that are signed and those still waiting for a signature, and leaves a player's request alone. Pass `{ signOutEverywhere: true, advance: true }`
|
|
440
|
+
when your store has the call and your host can move its clock: a host that then has none fails the contract, so the case cannot be skipped by leaving the call out.
|
|
441
|
+
|
|
442
|
+
**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).
|
|
443
|
+
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
|
|
444
|
+
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
|
|
445
|
+
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.
|
|
446
|
+
|
|
447
|
+
### The store (an adapter for your database)
|
|
448
|
+
|
|
449
|
+
`createAdminHandler` needs a store: three calls, `readSettings()`, `saveSettings(patch, now)` and `summary(now)`. Two adapters come with the package, and **both write only the two settings** (the D1 store also deletes the admin sessions, but only when the game turns on the sign-in list: see below)
|
|
450
|
+
(`setting:character` and `setting:signin`); every other call they make is a read. Both must pass the same **store contract**, `adminStoreConformance(make)` in
|
|
451
|
+
`@nano133/nanoterra/admin/conformance`: the settings are kept and accumulate, saving changes no order, no owned look and no other row, an open order past its expiry counts as expired,
|
|
452
|
+
the money is totalled right, the newest 500 orders come first, and a bad rate row is ignored. A game with another database writes its own store and runs the same contract.
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
import { createD1Store } from "@nano133/nanoterra/admin/d1"; // the game starter's tables: meta, orders, owned (migrations/0001_init.sql)
|
|
456
|
+
const store = createD1Store(env.DB); // Cloudflare's D1Database fits
|
|
457
|
+
|
|
458
|
+
import { createFirestoreStore } from "@nano133/nanoterra/admin/firestore"; // Firestore (the Admin SDK's `Firestore` fits the small interface)
|
|
459
|
+
const store = createFirestoreStore(db); // meta/{key}, orders/{id}, owned/{player__lookId}; the three names can be changed
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
The D1 adapter uses the starter's own queries (the counts and the totals over all orders; the list is the newest 500). The Firestore adapter reads the newest 5000 orders and every owned look,
|
|
463
|
+
so it is for a small game; its data model is in the file's top comment. **The Firestore adapter was tested against an in-memory fake of the interface, not against real Firestore.** The D1 adapter
|
|
464
|
+
was tested on SQLite with the starter's tables, not on Cloudflare's D1.
|
|
465
|
+
|
|
466
|
+
### Who signed in, and "Sign out everywhere" (optional)
|
|
467
|
+
|
|
468
|
+
A creator can see who opened the admin lately, and end every admin login at once. Both are optional: they exist only when the game's store has `recentLogins(limit)` and `signOutEverywhere()`.
|
|
469
|
+
With them the panel has a **Sign-ins** card (the last 10 admin sign-ins, newest first: the time, the rough country and a short browser name such as "Chrome on macOS") and a **Sign out
|
|
470
|
+
everywhere** button (two presses: "This ends every admin sign-in, in every browser, this one too"). The handler gets the route `POST {base}/signout-all` (behind the same guard, the same
|
|
471
|
+
origin rule and a limit of 5 a minute). It deletes the admin sessions and **no player session**, and says how many.
|
|
472
|
+
|
|
473
|
+
**What is kept, and for how long.** For each admin sign-in the game keeps three things: the **time**, the **country** that the request's country header gave (two letters, or none) and a **short
|
|
474
|
+
browser name** that the game makes from the browser text (a family and a system from fixed lists: "Chrome on macOS"). It keeps **no address, no IP and no browser text**. The handler sends only those
|
|
475
|
+
three things to the page, whatever the store gives. How long the game keeps them is the game's choice: **30 days is the suggestion**, and the game's `loginsNote` option says it on the card
|
|
476
|
+
(the starter deletes a row older than 30 days). The package cannot write a sign-in: the game writes one when it makes an admin login.
|
|
477
|
+
|
|
478
|
+
With D1: `createD1Store(db, { logins: true })` needs the table `admin_logins (id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, country TEXT, browser TEXT NOT NULL)`, a `scope` column on
|
|
479
|
+
`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
|
|
480
|
+
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
|
|
481
|
+
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.
|
|
482
|
+
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`.
|
|
483
|
+
|
|
484
|
+
### Later ideas (not built)
|
|
485
|
+
|
|
486
|
+
- **A second signature for a write.** The only server write the admin makes is the sign-in switch (and the Shelf never writes), so asking the owner to sign again for each press adds friction for
|
|
487
|
+
little gain. A later version could ask for it if the admin ever writes more.
|
|
488
|
+
- **An optional passkey for the admin, bound to the domain.** A passkey (WebAuthn) belongs to the domain it was made for, so a sign-in request that a stranger relays to the owner from another
|
|
489
|
+
page or link **cannot be answered with it**: the browser will not use the passkey on another domain. That is the strongest fix for the relay of an admin wallet sign-in. The wallet text names
|
|
490
|
+
the admin now, but a wallet cannot tell where a request started; a passkey can. Later, not now: the owner's wallet signature would stay as the way to make the first admin login or to
|
|
491
|
+
recover, and the passkey would be a second, optional way that cannot be relayed.
|
|
492
|
+
|
|
493
|
+
### The panel
|
|
494
|
+
|
|
495
|
+
```ts
|
|
496
|
+
import { mountAdmin } from "@nano133/nanoterra/admin/panel";
|
|
497
|
+
import "@nano133/nanoterra/admin/style.css";
|
|
498
|
+
|
|
499
|
+
mountAdmin(document.getElementById("admin")!, {
|
|
500
|
+
looks, // your looks.json as the game has it now: the Shelf starts from it
|
|
501
|
+
signIn: (into, done) => showWalletSignIn(into, done), // YOUR wallet sign-in for the ADMIN login; call done() when the owner has signed in
|
|
502
|
+
signOut: () => signOutOfAdmin(), // sign out of the ADMIN login only (the game's own login stays)
|
|
503
|
+
defaultsFile: "game.config.json", // optional: where your switches' defaults are written
|
|
504
|
+
});
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
The panel draws: the **Game** card (the two switches), what is still off (the status warnings), the totals, the **Shelf** (the looks with what each has sold, and a draft you can change),
|
|
508
|
+
**Orders** (with "Paid late" and a "Check payments now" button when the game gave a check), the looks that players own, and Sign out. A visitor who is signed out gets the game's
|
|
509
|
+
sign-in; a wrong wallet, a Nanoterra sign-in and a missing owner address each get their own screen and see no data. Options: `api` (the handler's base path), `noAddressHelp`,
|
|
510
|
+
`lookPicture(look)` (your own picture of a look for the Shelf), `repoHelp`, and `fetch`.
|
|
511
|
+
|
|
512
|
+
**The Shelf never writes.** It edits a draft in the owner's browser and gives text to copy (`looks.json`, or a message for an AI assistant): there is no call in the panel that
|
|
513
|
+
changes a look, a price or an order, and a test proves that editing the Shelf sends no request. A change of looks is a change of your repository. The **Game** card writes only the
|
|
514
|
+
two settings. The stylesheet is scoped under `.n133-admin`, so it cannot leak into your page; to theme it set `--n133-ink`, `--n133-ink-soft`, `--n133-paper`, `--n133-line`,
|
|
515
|
+
`--n133-gem`, `--n133-gem-dark`, `--n133-sea`, `--n133-radius` or `--n133-font` on the element that holds it. Every control is at least 40 px high (the order tabs); the switches, the price chips, the inputs and the buttons are 44 px or more. One column on a phone, two from 640 px.
|
|
516
|
+
|
|
517
|
+
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.
|
|
518
|
+
|
|
332
519
|
## Moving from the three old packages
|
|
333
520
|
|
|
334
521
|
| Before | Now |
|
package/ai/SPEC.md
CHANGED
|
@@ -99,9 +99,10 @@ export const LOOKS = checkLooks(rawLooksJson); // throws with the first mistake
|
|
|
99
99
|
- Before a change goes live: `[...lookProblems(next), ...removalProblems(current, next, ownedLookIds)]`; show `diffLooks(current, next)`; write the file with `looksJson(next)`.
|
|
100
100
|
- One free look (`priceUsd: 0`) at least. Never change or remove the id of a look that someone owns. Prices in US dollars with at most 2 decimals; the server reads the price from the file.
|
|
101
101
|
- A look only changes how the character looks. No random look, no loot box, no look that changes a score.
|
|
102
|
+
- `slots` (optional, at most 8: a name and a word or a `#rrggbb` colour) is where the game keeps its own parts of a look. Read `look.slots` from `checkLooks`. Never change the slots of a look that someone owns: add a new look.
|
|
102
103
|
- The game is the seller. Never send looks, prices or sales to another site.
|
|
103
104
|
|
|
104
|
-
**Check:** `checkLooks` passes at start; a removed look that is owned is refused.
|
|
105
|
+
**Check:** `checkLooks` passes at start; a removed look that is owned is refused; a changed slot on an owned look is refused.
|
|
105
106
|
|
|
106
107
|
## 8. Do not
|
|
107
108
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AdminStore } from "../store.js";
|
|
2
|
+
/** The part of D1 that the adapter uses. Cloudflare's `D1Database` fits it. */
|
|
3
|
+
export interface D1StatementLike {
|
|
4
|
+
bind(...values: unknown[]): D1StatementLike;
|
|
5
|
+
all<T = unknown>(): Promise<{
|
|
6
|
+
results: T[];
|
|
7
|
+
}>;
|
|
8
|
+
first<T = unknown>(): Promise<T | null>;
|
|
9
|
+
run(): Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
export interface D1Like {
|
|
12
|
+
prepare(query: string): D1StatementLike;
|
|
13
|
+
batch(statements: D1StatementLike[]): Promise<unknown[]>;
|
|
14
|
+
}
|
|
15
|
+
export declare const D1_KEYS: {
|
|
16
|
+
readonly character: "setting:character";
|
|
17
|
+
readonly signin: "setting:signin";
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The admin store for D1. With `logins: true` it also lists the last admin sign-ins (the table `admin_logins`: at, country, browser) and can sign the owner out everywhere
|
|
21
|
+
* (`DELETE FROM sessions WHERE scope = 'admin'`, and `UPDATE signin_requests SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0` so that a request signed a moment ago
|
|
22
|
+
* cannot make a new login afterwards, and a wallet app that is answered later is told that the request has ended): the game's `sessions` and `signin_requests` tables must have a `scope` column ('admin' for an admin login or request) and the game must write
|
|
23
|
+
* `admin_logins` when it makes an admin login. Without the option the store has neither call, so a game that has not made those tables is not broken by it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createD1Store(db: D1Like, options?: {
|
|
26
|
+
logins?: boolean;
|
|
27
|
+
}): AdminStore;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The admin store for a game on Cloudflare D1, with the tables of the game starter (migrations/0001_init.sql): `meta`, `orders` and `owned`.
|
|
2
|
+
// It writes ONLY the two settings (`setting:character`, `setting:signin` in `meta`); everything else it does is a read, except when the game turns the sign-in list on
|
|
3
|
+
// (`logins: true`): then the one other write is `DELETE FROM sessions WHERE scope = 'admin'` ("sign out everywhere").
|
|
4
|
+
//
|
|
5
|
+
// import { createD1Store } from "@nano133/nanoterra/admin/d1";
|
|
6
|
+
// const store = createD1Store(env.DB);
|
|
7
|
+
import { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers } from "../summary.js";
|
|
8
|
+
export const D1_KEYS = { character: "setting:character", signin: "setting:signin" };
|
|
9
|
+
/**
|
|
10
|
+
* The admin store for D1. With `logins: true` it also lists the last admin sign-ins (the table `admin_logins`: at, country, browser) and can sign the owner out everywhere
|
|
11
|
+
* (`DELETE FROM sessions WHERE scope = 'admin'`, and `UPDATE signin_requests SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0` so that a request signed a moment ago
|
|
12
|
+
* cannot make a new login afterwards, and a wallet app that is answered later is told that the request has ended): the game's `sessions` and `signin_requests` tables must have a `scope` column ('admin' for an admin login or request) and the game must write
|
|
13
|
+
* `admin_logins` when it makes an admin login. Without the option the store has neither call, so a game that has not made those tables is not broken by it.
|
|
14
|
+
*/
|
|
15
|
+
export function createD1Store(db, options = {}) {
|
|
16
|
+
const store = {
|
|
17
|
+
async readSettings() {
|
|
18
|
+
const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?)").bind(D1_KEYS.character, D1_KEYS.signin).all()).results;
|
|
19
|
+
const row = (key) => rows.find((r) => r.key === key)?.value;
|
|
20
|
+
return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin) };
|
|
21
|
+
},
|
|
22
|
+
async saveSettings(patch, now) {
|
|
23
|
+
const write = (key, value) => db.prepare("INSERT INTO meta (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at").bind(key, value, now);
|
|
24
|
+
const batch = [];
|
|
25
|
+
if (patch.character)
|
|
26
|
+
batch.push(write(D1_KEYS.character, patch.character));
|
|
27
|
+
if (patch.signin)
|
|
28
|
+
batch.push(write(D1_KEYS.signin, patch.signin));
|
|
29
|
+
if (batch.length)
|
|
30
|
+
await db.batch(batch);
|
|
31
|
+
},
|
|
32
|
+
async summary(now) {
|
|
33
|
+
// The game starter's own queries: the counts and the totals are over ALL orders, the list is the newest ORDERS_SHOWN.
|
|
34
|
+
const orders = (await db
|
|
35
|
+
.prepare("SELECT id, player, look_id, usd, amount, status, created_at, expires_at, paid_at, payer, hash FROM orders ORDER BY created_at DESC LIMIT ?")
|
|
36
|
+
.bind(ORDERS_SHOWN)
|
|
37
|
+
.all()).results;
|
|
38
|
+
const owned = (await db.prepare("SELECT player, look_id, granted_at FROM owned ORDER BY player, granted_at").all()).results;
|
|
39
|
+
const sums = (await db.prepare("SELECT status, amount, usd FROM orders WHERE status IN ('paid', 'paid_late')").all()).results;
|
|
40
|
+
const grouped = (await db.prepare("SELECT status, expires_at < ? AS past, COUNT(*) AS n FROM orders GROUP BY status, past").bind(now).all()).results;
|
|
41
|
+
const rate = await db.prepare("SELECT value, at FROM meta WHERE key = 'rate'").first();
|
|
42
|
+
const counts = { open: 0, paid: 0, expired: 0, paid_late: 0 };
|
|
43
|
+
for (const c of grouped) {
|
|
44
|
+
const key = c.status === "open" && c.past ? "expired" : c.status;
|
|
45
|
+
if (Object.hasOwn(counts, key))
|
|
46
|
+
counts[key] += c.n;
|
|
47
|
+
}
|
|
48
|
+
const money = (status) => moneyOf(sums.filter((s) => s.status === status).map((s) => ({ amountRaw: s.amount, usd: s.usd })));
|
|
49
|
+
const rows = orders.map((o) => ({ id: o.id, player: o.player, lookId: o.look_id, usd: o.usd, amountRaw: o.amount, status: o.status, createdAt: o.created_at, expiresAt: o.expires_at, paidAt: o.paid_at, payer: o.payer, hash: o.hash }));
|
|
50
|
+
return {
|
|
51
|
+
counts,
|
|
52
|
+
totals: { paid: money("paid"), paidLate: money("paid_late") },
|
|
53
|
+
orders: shapeOrders(rows, now),
|
|
54
|
+
players: shapePlayers(owned.map((o) => ({ player: o.player, lookId: o.look_id, at: o.granted_at }))),
|
|
55
|
+
rate: parseRate(rate?.value, rate?.at),
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
if (options.logins) {
|
|
60
|
+
store.recentLogins = async (limit) => {
|
|
61
|
+
const n = Math.min(Math.max(Math.floor(Number(limit)) || 0, 0), 50);
|
|
62
|
+
const rows = (await db.prepare("SELECT at, country, browser FROM admin_logins ORDER BY at DESC, id DESC LIMIT ?").bind(n).all()).results;
|
|
63
|
+
return rows.map((r) => ({ at: r.at, country: r.country, browser: r.browser }));
|
|
64
|
+
};
|
|
65
|
+
// One batch: the admin sessions are deleted, AND every admin sign-in request that was started but not delivered is marked delivered, so a request that the owner signed a moment
|
|
66
|
+
// ago cannot make a new admin login after "Sign out everywhere" (its check would answer "used").
|
|
67
|
+
store.signOutEverywhere = async () => {
|
|
68
|
+
const done = (await db.batch([db.prepare("DELETE FROM sessions WHERE scope = 'admin'"), db.prepare("UPDATE signin_requests SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0")]));
|
|
69
|
+
return done[0]?.meta?.changes ?? 0;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return store;
|
|
73
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AdminStore } from "../store.js";
|
|
2
|
+
export interface FirestoreDocLike {
|
|
3
|
+
get(): Promise<{
|
|
4
|
+
exists: boolean;
|
|
5
|
+
data(): Record<string, unknown> | undefined;
|
|
6
|
+
}>;
|
|
7
|
+
}
|
|
8
|
+
export interface FirestoreQueryLike {
|
|
9
|
+
orderBy(field: string, direction?: "asc" | "desc"): FirestoreQueryLike;
|
|
10
|
+
limit(n: number): FirestoreQueryLike;
|
|
11
|
+
get(): Promise<{
|
|
12
|
+
docs: {
|
|
13
|
+
id: string;
|
|
14
|
+
data(): Record<string, unknown>;
|
|
15
|
+
}[];
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export interface FirestoreCollectionLike extends FirestoreQueryLike {
|
|
19
|
+
doc(id: string): FirestoreDocLike;
|
|
20
|
+
}
|
|
21
|
+
export interface FirestoreBatchLike {
|
|
22
|
+
set(doc: FirestoreDocLike, data: Record<string, unknown>, options?: {
|
|
23
|
+
merge?: boolean;
|
|
24
|
+
}): unknown;
|
|
25
|
+
commit(): Promise<unknown>;
|
|
26
|
+
}
|
|
27
|
+
export interface FirestoreLike {
|
|
28
|
+
collection(name: string): FirestoreCollectionLike;
|
|
29
|
+
batch(): FirestoreBatchLike;
|
|
30
|
+
}
|
|
31
|
+
export declare const FIRESTORE_SCAN_LIMIT = 5000;
|
|
32
|
+
export declare const FIRESTORE_KEYS: {
|
|
33
|
+
readonly character: "setting:character";
|
|
34
|
+
readonly signin: "setting:signin";
|
|
35
|
+
};
|
|
36
|
+
export declare function createFirestoreStore(db: FirestoreLike, collections?: {
|
|
37
|
+
meta?: string;
|
|
38
|
+
orders?: string;
|
|
39
|
+
owned?: string;
|
|
40
|
+
}): AdminStore;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The admin store for a game on Firestore (the Admin SDK). The package has no Firebase dependency: the adapter uses the small interface below, which the
|
|
2
|
+
// Admin SDK's `Firestore` fits. It writes ONLY the two settings; everything else it does is a read.
|
|
3
|
+
//
|
|
4
|
+
// The data model (the names a game must have; only the three collection names can be changed):
|
|
5
|
+
// meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "rate" (value is JSON { usd, sources })
|
|
6
|
+
// orders/{id} { player, lookId, usd, amount, status, createdAt, expiresAt, paidAt, payer, hash } `amount` is the raw amount as text
|
|
7
|
+
// owned/{player__lookId} { player, lookId, orderId, grantedAt }
|
|
8
|
+
//
|
|
9
|
+
// The summary reads the newest 5000 orders (the counts and the totals are over those) and every owned look: for a small game. A big game should keep running totals.
|
|
10
|
+
import { parseRate, summarize } from "../summary.js";
|
|
11
|
+
export const FIRESTORE_SCAN_LIMIT = 5000;
|
|
12
|
+
export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin" };
|
|
13
|
+
const text = (v, fallback = "") => (typeof v === "string" ? v : fallback);
|
|
14
|
+
const num = (v, fallback = 0) => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
|
|
15
|
+
const orNull = (v) => (typeof v === "string" ? v : null);
|
|
16
|
+
export function createFirestoreStore(db, collections = {}) {
|
|
17
|
+
const meta = collections.meta ?? "meta";
|
|
18
|
+
const orders = collections.orders ?? "orders";
|
|
19
|
+
const owned = collections.owned ?? "owned";
|
|
20
|
+
const valueOf = async (key) => {
|
|
21
|
+
const snap = await db.collection(meta).doc(key).get();
|
|
22
|
+
return snap.exists ? snap.data() : undefined;
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
async readSettings() {
|
|
26
|
+
const [character, signin] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin)]);
|
|
27
|
+
return { character: character?.value, signin: signin?.value };
|
|
28
|
+
},
|
|
29
|
+
async saveSettings(patch, now) {
|
|
30
|
+
const batch = db.batch();
|
|
31
|
+
let n = 0;
|
|
32
|
+
if (patch.character)
|
|
33
|
+
(batch.set(db.collection(meta).doc(FIRESTORE_KEYS.character), { value: patch.character, at: now }), n++);
|
|
34
|
+
if (patch.signin)
|
|
35
|
+
(batch.set(db.collection(meta).doc(FIRESTORE_KEYS.signin), { value: patch.signin, at: now }), n++);
|
|
36
|
+
if (n)
|
|
37
|
+
await batch.commit();
|
|
38
|
+
},
|
|
39
|
+
async summary(now) {
|
|
40
|
+
const [orderDocs, ownedDocs, rate] = await Promise.all([
|
|
41
|
+
db.collection(orders).orderBy("createdAt", "desc").limit(FIRESTORE_SCAN_LIMIT).get(),
|
|
42
|
+
db.collection(owned).get(),
|
|
43
|
+
valueOf("rate"),
|
|
44
|
+
]);
|
|
45
|
+
const rows = orderDocs.docs.map((d) => {
|
|
46
|
+
const o = d.data();
|
|
47
|
+
return { id: d.id, player: text(o.player), lookId: text(o.lookId), usd: text(o.usd, "0"), amountRaw: text(o.amount, "0"), status: text(o.status, "open"), createdAt: num(o.createdAt), expiresAt: num(o.expiresAt), paidAt: typeof o.paidAt === "number" ? o.paidAt : null, payer: orNull(o.payer), hash: orNull(o.hash) };
|
|
48
|
+
});
|
|
49
|
+
const list = ownedDocs.docs.map((d) => ({ player: text(d.data().player), lookId: text(d.data().lookId), at: num(d.data().grantedAt) }));
|
|
50
|
+
return summarize(rows, list, parseRate(rate?.value, rate?.at), now);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const RAW_PER_XNO: bigint;
|
|
2
|
+
/** A raw amount as XNO with every significant digit: "0.2512". Throws on text that is not a whole number. */
|
|
3
|
+
export declare function xnoText(raw: string): string;
|
|
4
|
+
/** A raw amount that can be added: digits only, at most 39 of them. Anything else is not counted (a bad row must not break the summary). */
|
|
5
|
+
export declare const isRawText: (s: unknown) => s is string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Nano amounts for the summary. 1 XNO is 10^30 raw, more than a JavaScript number holds exactly, so amounts are decimal strings and the sums are BigInt.
|
|
2
|
+
export const RAW_PER_XNO = 10n ** 30n;
|
|
3
|
+
/** A raw amount as XNO with every significant digit: "0.2512". Throws on text that is not a whole number. */
|
|
4
|
+
export function xnoText(raw) {
|
|
5
|
+
const n = BigInt(raw);
|
|
6
|
+
const whole = n / RAW_PER_XNO;
|
|
7
|
+
const frac = (n % RAW_PER_XNO).toString().padStart(30, "0").replace(/0+$/, "");
|
|
8
|
+
return frac ? `${whole}.${frac}` : `${whole}`;
|
|
9
|
+
}
|
|
10
|
+
/** A raw amount that can be added: digits only, at most 39 of them. Anything else is not counted (a bad row must not break the summary). */
|
|
11
|
+
export const isRawText = (s) => typeof s === "string" && /^[0-9]{1,39}$/.test(s);
|