@nano133/nanoterra 0.8.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +390 -6
  3. package/ai/SPEC.md +71 -3
  4. package/dist/admin/panel/mount.d.ts +3 -0
  5. package/dist/admin/panel/mount.js +4 -2
  6. package/dist/player-signin/adapters/d1.d.ts +10 -0
  7. package/dist/player-signin/adapters/d1.js +108 -0
  8. package/dist/player-signin/adapters/firestore.d.ts +45 -0
  9. package/dist/player-signin/adapters/firestore.js +168 -0
  10. package/dist/player-signin/conformance.d.ts +11 -0
  11. package/dist/player-signin/conformance.js +160 -0
  12. package/dist/player-signin/handler.d.ts +64 -0
  13. package/dist/player-signin/handler.js +364 -0
  14. package/dist/player-signin/host.d.ts +4 -0
  15. package/dist/player-signin/host.js +10 -0
  16. package/dist/player-signin/index.d.ts +9 -0
  17. package/dist/player-signin/index.js +15 -0
  18. package/dist/player-signin/memory.d.ts +15 -0
  19. package/dist/player-signin/memory.js +101 -0
  20. package/dist/player-signin/panel/index.d.ts +9 -0
  21. package/dist/player-signin/panel/index.js +8 -0
  22. package/dist/player-signin/panel/memo.d.ts +12 -0
  23. package/dist/player-signin/panel/memo.js +41 -0
  24. package/dist/player-signin/panel/mount.d.ts +21 -0
  25. package/dist/player-signin/panel/mount.js +107 -0
  26. package/dist/player-signin/panel/view.d.ts +48 -0
  27. package/dist/player-signin/panel/view.js +363 -0
  28. package/dist/player-signin/panel/words.d.ts +103 -0
  29. package/dist/player-signin/panel/words.js +111 -0
  30. package/dist/player-signin/scheme-check.d.ts +3 -0
  31. package/dist/player-signin/scheme-check.js +14 -0
  32. package/dist/player-signin/scheme.d.ts +13 -0
  33. package/dist/player-signin/scheme.js +28 -0
  34. package/dist/player-signin/types.d.ts +116 -0
  35. package/dist/player-signin/types.js +33 -0
  36. package/dist/shop/adapters/d1.d.ts +25 -0
  37. package/dist/shop/adapters/d1.js +200 -0
  38. package/dist/shop/adapters/firestore.d.ts +53 -0
  39. package/dist/shop/adapters/firestore.js +245 -0
  40. package/dist/shop/address.d.ts +14 -0
  41. package/dist/shop/address.js +104 -0
  42. package/dist/shop/amount.d.ts +26 -0
  43. package/dist/shop/amount.js +59 -0
  44. package/dist/shop/conformance.d.ts +13 -0
  45. package/dist/shop/conformance.js +203 -0
  46. package/dist/shop/handler.d.ts +76 -0
  47. package/dist/shop/handler.js +156 -0
  48. package/dist/shop/index.d.ts +19 -0
  49. package/dist/shop/index.js +21 -0
  50. package/dist/shop/match.d.ts +23 -0
  51. package/dist/shop/match.js +58 -0
  52. package/dist/shop/memory.d.ts +13 -0
  53. package/dist/shop/memory.js +106 -0
  54. package/dist/shop/nodes.d.ts +47 -0
  55. package/dist/shop/nodes.js +106 -0
  56. package/dist/shop/orders.d.ts +47 -0
  57. package/dist/shop/orders.js +82 -0
  58. package/dist/shop/panel/index.d.ts +9 -0
  59. package/dist/shop/panel/index.js +14 -0
  60. package/dist/shop/panel/looks.d.ts +33 -0
  61. package/dist/shop/panel/looks.js +61 -0
  62. package/dist/shop/panel/mount.d.ts +68 -0
  63. package/dist/shop/panel/mount.js +221 -0
  64. package/dist/shop/panel/pay.d.ts +27 -0
  65. package/dist/shop/panel/pay.js +128 -0
  66. package/dist/shop/panel/qr.d.ts +9 -0
  67. package/dist/shop/panel/qr.js +281 -0
  68. package/dist/shop/panel/words.d.ts +89 -0
  69. package/dist/shop/panel/words.js +79 -0
  70. package/dist/shop/price.d.ts +23 -0
  71. package/dist/shop/price.js +43 -0
  72. package/dist/shop/scan.d.ts +24 -0
  73. package/dist/shop/scan.js +80 -0
  74. package/dist/shop/security.d.ts +10 -0
  75. package/dist/shop/security.js +56 -0
  76. package/dist/shop/store.d.ts +101 -0
  77. package/dist/shop/store.js +4 -0
  78. package/dist/shop/worker.d.ts +43 -0
  79. package/dist/shop/worker.js +76 -0
  80. package/package.json +21 -6
  81. package/src/admin/panel/mount.ts +7 -2
  82. package/src/player-signin/adapters/d1.sql +45 -0
  83. package/src/player-signin/adapters/d1.ts +120 -0
  84. package/src/player-signin/adapters/firestore.ts +194 -0
  85. package/src/player-signin/conformance.ts +184 -0
  86. package/src/player-signin/handler.ts +404 -0
  87. package/src/player-signin/host.ts +12 -0
  88. package/src/player-signin/index.ts +20 -0
  89. package/src/player-signin/memory.ts +94 -0
  90. package/src/player-signin/panel/index.ts +13 -0
  91. package/src/player-signin/panel/memo.ts +49 -0
  92. package/src/player-signin/panel/mount.ts +129 -0
  93. package/src/player-signin/panel/style.css +478 -0
  94. package/src/player-signin/panel/view.ts +433 -0
  95. package/src/player-signin/panel/words.ts +210 -0
  96. package/src/player-signin/scheme-check.ts +14 -0
  97. package/src/player-signin/scheme.ts +32 -0
  98. package/src/player-signin/types.ts +103 -0
  99. package/src/shop/adapters/d1.sql +52 -0
  100. package/src/shop/adapters/d1.ts +245 -0
  101. package/src/shop/adapters/firestore.ts +281 -0
  102. package/src/shop/address.ts +102 -0
  103. package/src/shop/amount.ts +66 -0
  104. package/src/shop/conformance.ts +232 -0
  105. package/src/shop/handler.ts +202 -0
  106. package/src/shop/index.ts +30 -0
  107. package/src/shop/match.ts +63 -0
  108. package/src/shop/memory.ts +102 -0
  109. package/src/shop/nodes.ts +115 -0
  110. package/src/shop/orders.ts +93 -0
  111. package/src/shop/panel/index.ts +19 -0
  112. package/src/shop/panel/looks.ts +106 -0
  113. package/src/shop/panel/mount.ts +280 -0
  114. package/src/shop/panel/pay.ts +192 -0
  115. package/src/shop/panel/qr.ts +256 -0
  116. package/src/shop/panel/style.css +538 -0
  117. package/src/shop/panel/words.ts +172 -0
  118. package/src/shop/price.ts +52 -0
  119. package/src/shop/scan.ts +90 -0
  120. package/src/shop/security.ts +50 -0
  121. package/src/shop/store.ts +99 -0
  122. package/src/shop/worker.ts +107 -0
@@ -0,0 +1,32 @@
1
+ // The game's passkey SCHEME: the texts that turn a passkey's secret (its PRF output) into the player's Nano account. It is the game's own, and it is
2
+ // FROZEN: a player's looks belong to the address these exact texts give.
3
+ //
4
+ // PRF input = UTF-8 scheme.input
5
+ // seed = HKDF-SHA-256(ikm = the PRF output, salt = UTF-8 scheme.salt, info = UTF-8 scheme.info, 32 bytes)
6
+ // account = the seed as 64 hex characters, account 0
7
+ //
8
+ // Write it one time, in one file of the game, and test it with fixed vectors (`schemeVector`): a PRF output, its seed, its address. A change of one
9
+ // character gives every player another account (and so no looks). A new scheme is a new `versionByte` with new texts, never an edit. The kit offers
10
+ // no default on purpose: a default in a library could change under the game.
11
+
12
+ import { seedFromPrf, type PasskeyScheme } from "@nano133/signin/passkey";
13
+ import { purseAddress } from "@nano133/signin/purse";
14
+ import { checkScheme } from "./scheme-check.js";
15
+
16
+ export type { PasskeyScheme };
17
+ export { checkScheme };
18
+
19
+ const bytesOf = (prfHex: string) => {
20
+ if (!/^[0-9a-fA-F]{64}$/.test(prfHex)) throw new Error("a PRF output is 64 hex characters (32 bytes)");
21
+ return Uint8Array.from(prfHex.match(/../g)!.map((b) => parseInt(b, 16)));
22
+ };
23
+
24
+ /**
25
+ * One fixed vector of a scheme: the seed and the address that a PRF output gives. For the game's own test, which must never change:
26
+ *
27
+ * assert.deepEqual(await schemeVector(SCHEME, "0102…1f20"), { seed: "A3D5…", address: "nano_14kj…" });
28
+ */
29
+ export async function schemeVector(scheme: PasskeyScheme, prfHex: string): Promise<{ seed: string; address: string }> {
30
+ const seed = await seedFromPrf(bytesOf(prfHex), checkScheme(scheme));
31
+ return { seed, address: purseAddress(seed) };
32
+ }
@@ -0,0 +1,103 @@
1
+ // The PLAYER sign-in of a game: the types, the limits and the store contract. (The admin's sign-in is another thing: "@nano133/nanoterra/admin".)
2
+ //
3
+ // A player is a Nano address. Every way to sign in is a signature from a key the player holds, and the server holds no key:
4
+ // - a passkey purse: a Nano account made in the browser from the player's passkey (its PRF output), by the game's FROZEN scheme;
5
+ // the browser signs a short text that names the game, and the server checks the signature (no WebAuthn on the server);
6
+ // - Berry Wallet (nano-signin-v1), NanChat (nanauth) and Nautilus (nanoauth): the wallet app signs a request that the browser started.
7
+ // The checks are @nano133/signin's. This file names no package: the stores and their adapters work without it.
8
+
9
+ /** The wallet apps a player can sign in with, in the order the sheet shows them. */
10
+ export const WAYS = ["berry", "nanchat", "nautilus"] as const;
11
+ export type Way = (typeof WAYS)[number];
12
+
13
+ /** How a session was made. "wallet": a wallet app's signature. "passkey": a signature by a key in the page (the passkey purse). "nanoterra": the game's own code exchange. */
14
+ export type Via = "wallet" | "passkey" | "nanoterra";
15
+ export const VIAS: readonly Via[] = ["wallet", "passkey", "nanoterra"];
16
+
17
+ /** A wallet app's sign-in request, as a store keeps it. The same shape as @nano133/signin's `WalletRequest`. */
18
+ export type SigninRequest = {
19
+ /** The browser's id for its checks. */
20
+ id: string;
21
+ /** The SHA-256 (hex) of the starter cookie of the browser that asked. */
22
+ starter: string;
23
+ status: "waiting" | "signedin";
24
+ /** When it stops taking a signature (ms). */
25
+ expiresAt: number;
26
+ /** Nautilus: the label the app shows and signs. */
27
+ label?: string;
28
+ /** Berry Wallet: the nonce and when it was issued (ms). */
29
+ nonce?: string;
30
+ issuedAt?: number;
31
+ /** The account that signed, once it is signed. */
32
+ account?: string;
33
+ };
34
+
35
+ /**
36
+ * The requests of ONE wallet app. The same methods as @nano133/signin's `RequestStore`, so the package's functions take it as it is. `key` is the
37
+ * request's id for Berry Wallet and Nautilus, and the SHA-256 of the nonce for NanChat (whose answer names only the nonce). `finish` is atomic:
38
+ * waiting → signed, once; false otherwise.
39
+ */
40
+ export interface RequestStoreLike {
41
+ create(key: string, r: SigninRequest): Promise<void>;
42
+ get(key: string): Promise<SigninRequest | null>;
43
+ find(id: string): Promise<{ key: string; request: SigninRequest } | null>;
44
+ finish(key: string, account: string): Promise<boolean>;
45
+ }
46
+
47
+ /** A session as a store keeps it. `idHash` is the SHA-256 (hex) of the cookie's token: the token itself is never stored. */
48
+ export type SessionRecord = { idHash: string; address: string; createdAt: number; expiresAt: number; via: Via };
49
+
50
+ /** A session as the game reads it. `via` is "unknown" for a word the store holds that is none of VIAS (never read as a wallet). */
51
+ export type PlayerSession = { address: string; createdAt: number; via: Via | "unknown" };
52
+
53
+ /**
54
+ * The game's database for the player sign-in. An adapter reads and writes ONLY its three tables or collections (requests, sessions, once-only
55
+ * keys), and only the PLAYER's rows of them (a game whose admin shares the tables keeps its rows apart: the D1 adapter's `scope` column).
56
+ * Run `playerSigninStoreConformance` on a store you write yourself.
57
+ */
58
+ export interface PlayerSigninStore {
59
+ /** The requests of one wallet app. */
60
+ requests(way: Way): RequestStoreLike;
61
+ /**
62
+ * Delivers a signed request to the browser that started it, ONCE, and writes its session in the same step: true when this call delivered it,
63
+ * false (and no session written) when it was delivered before, is not signed, or is not known.
64
+ */
65
+ deliver(way: Way, key: string, session: SessionRecord): Promise<boolean>;
66
+ createSession(session: SessionRecord): Promise<void>;
67
+ /** The session of a token's hash while it lasts (`expiresAt` > now), else null. */
68
+ session(idHash: string, now: number): Promise<SessionRecord | null>;
69
+ endSession(idHash: string): Promise<void>;
70
+ /** Records `key` and answers true; answers false when it is there already. `address` is kept on the row (for `forget`). The passkey route's replay guard. */
71
+ useOnce(key: string, ttlMs: number, address: string): Promise<boolean>;
72
+ /** A player's "Delete my data": every session, every signed request and every once-only row of `address`. Returns how many rows went. */
73
+ forget(address: string): Promise<number>;
74
+ /** Deletes what nobody needs any more: requests 2 hours after they ended, sessions that ended, once-only rows past their time. */
75
+ clean(now: number): Promise<void>;
76
+ }
77
+
78
+ /** The limits of the routes (calls per window). The two marked "hook" use the game's `limit`; the rest count in this copy's memory. */
79
+ export const PLAYER_SIGNIN_LIMITS = {
80
+ /** A wallet sign-in start or a passkey sign-in, per visitor (hook). */
81
+ signin: { limit: 20, windowMs: 10 * 60_000 },
82
+ /** The browser's checks (every 2 s), per visitor (memory). */
83
+ poll: { limit: 90, windowMs: 60_000 },
84
+ /** Berry Wallet's GET and POST and Nautilus's answer, per IP address (memory): the wallet's own phone sends them. */
85
+ wallet: { limit: 30, windowMs: 60_000 },
86
+ /** NanChat's answers all come from its one relay server: the whole route (memory), the answers of the right shape (hook) and each nonce (hook). */
87
+ nanchatRoute: { limit: 120, windowMs: 60_000 },
88
+ nanchatAll: { limit: 600, windowMs: 10 * 60_000 },
89
+ nanchatNonce: { limit: 5, windowMs: 10 * 60_000 },
90
+ /** The biggest body the routes read. */
91
+ bodyMax: 8192,
92
+ } as const;
93
+
94
+ /** How long a player's session lasts (the game starter's 30 days). */
95
+ export const SESSION_DAYS = 30;
96
+ /** How long the starter cookie lives: a wallet's request must be answered within it. */
97
+ export const STARTER_MAX_AGE_S = 30 * 60;
98
+
99
+ /** A token, a starter value: 32 random bytes as 64 hex characters. */
100
+ export const TOKEN_PATTERN = /^[0-9a-f]{64}$/;
101
+
102
+ /** A store's word for `via`, as the game reads it. */
103
+ export const viaOf = (v: unknown): Via | "unknown" => (VIAS.includes(v as Via) ? (v as Via) : "unknown");
@@ -0,0 +1,52 @@
1
+ -- The shop tables of "@nano133/nanoterra/shop/d1": the game starter's own (migrations/0001_init.sql), column for column. A new game adds this file to its
2
+ -- migrations (for example migrations/0002_shop.sql) and runs `wrangler d1 migrations apply`. A game made from the starter has these tables already:
3
+ -- it needs no migration (and IF NOT EXISTS makes this file a no-op there). The store reads and writes only the first three tables; `d1Limit` the fourth.
4
+ -- Times are milliseconds since 1970 (Date.now()). Amounts are raw, as text (1 XNO = 10^30 raw).
5
+
6
+ -- One row per order (a player asked to buy a look).
7
+ CREATE TABLE IF NOT EXISTS orders (
8
+ id TEXT PRIMARY KEY, -- 24 hex characters
9
+ player TEXT NOT NULL, -- the signed-in player's id (the starter's: the nano_ address)
10
+ look_id TEXT NOT NULL,
11
+ usd TEXT NOT NULL, -- the price in US dollars when the order was made: "0.25"
12
+ rate REAL NOT NULL, -- dollars per XNO that turned the price into XNO
13
+ amount TEXT NOT NULL, -- the exact amount to pay, a clean amount (at most 4 decimals of XNO)
14
+ -- The reservation: equal to `amount` while a payment of it may still be matched to this order, NULL
15
+ -- after. UNIQUE, so no two held orders ever ask for the same amount (NULLs never collide).
16
+ amount_lock TEXT UNIQUE,
17
+ status TEXT NOT NULL DEFAULT 'open', -- open | paid | expired | paid_late
18
+ created_at INTEGER NOT NULL,
19
+ expires_at INTEGER NOT NULL, -- the order takes a payment until then
20
+ hold_until INTEGER NOT NULL, -- the amount stays reserved until then (late payments are still seen)
21
+ hash TEXT UNIQUE, -- the paying send block: one payment pays one order, once
22
+ payer TEXT, -- the account that sent it
23
+ paid_at INTEGER, -- when the node first saw that send
24
+ visitor TEXT -- a keyed hash of the buyer's IP address (limits, never the address)
25
+ );
26
+ CREATE INDEX IF NOT EXISTS orders_by_status ON orders (status, expires_at);
27
+ CREATE INDEX IF NOT EXISTS orders_by_player ON orders (player, look_id, status);
28
+
29
+ -- The looks each player owns. One row per player and look: a second grant changes nothing.
30
+ CREATE TABLE IF NOT EXISTS owned (
31
+ player TEXT NOT NULL,
32
+ look_id TEXT NOT NULL,
33
+ order_id TEXT NOT NULL,
34
+ granted_at INTEGER NOT NULL,
35
+ PRIMARY KEY (player, look_id)
36
+ );
37
+
38
+ -- Small shared values. The shop keeps "rate" (the last good XNO price, JSON { usd, sources }), "scan" (the time of the last payment scan) and "salt" (`visitorKey`).
39
+ -- The admin keeps its settings here too ("setting:character" and the rest).
40
+ CREATE TABLE IF NOT EXISTS meta (
41
+ key TEXT PRIMARY KEY,
42
+ value TEXT NOT NULL,
43
+ at INTEGER NOT NULL
44
+ );
45
+
46
+ -- Rate-limit counters (`d1Limit`): one row per key and time window. The game deletes old windows (the starter's cron: those older than a day).
47
+ CREATE TABLE IF NOT EXISTS hits (
48
+ key TEXT NOT NULL,
49
+ win INTEGER NOT NULL,
50
+ count INTEGER NOT NULL,
51
+ PRIMARY KEY (key, win)
52
+ );
@@ -0,0 +1,245 @@
1
+ // The shop store for a game on Cloudflare D1, on the game starter's own tables (migrations/0001_init.sql): `orders`, `owned` and `meta`. The package
2
+ // ships the same tables as "@nano133/nanoterra/shop/d1.sql" (CREATE ... IF NOT EXISTS) for a new game; the starter has them already, so it switches with
3
+ // no migration and keeps every order and owned look.
4
+ //
5
+ // import { createD1Shop } from "@nano133/nanoterra/shop/d1";
6
+ // const store = createD1Shop(env.DB);
7
+ //
8
+ // It reads and writes ONLY those three tables (in `meta`, only the keys "rate" and "scan"). The table names can be changed (`tables`), but the admin's
9
+ // D1 store ("@nano133/nanoterra/admin/d1") reads the starter's names: keep them unless the game has its own admin store.
10
+ //
11
+ // The money rules are in the statements themselves: `amount_lock` and `hash` are UNIQUE (no two held orders ask for one amount, one payment pays one
12
+ // order), the payment and the grant are ONE batch (a D1 transaction), and the scan's claim is one upsert that only one caller in a gap can win.
13
+
14
+ import type { D1Like } from "../../admin/adapters/d1.js";
15
+ import { parseRate } from "../../admin/summary.js";
16
+ import { paidStatus } from "../orders.js";
17
+ import { FORGOTTEN, type ForgetResult, type OrderStatus, type Settled, type ShopOrder, type ShopStore } from "../store.js";
18
+
19
+ export type { D1Like, D1StatementLike } from "../../admin/adapters/d1.js";
20
+
21
+ /** The table names. The defaults are the game starter's. (`hits` is for `d1Limit` only.) */
22
+ export type ShopTables = { orders?: string; owned?: string; meta?: string; hits?: string };
23
+
24
+ type Row = {
25
+ id: string;
26
+ player: string;
27
+ look_id: string;
28
+ usd: string;
29
+ rate: number;
30
+ amount: string;
31
+ amount_lock: string | null;
32
+ status: string;
33
+ created_at: number;
34
+ expires_at: number;
35
+ hold_until: number;
36
+ hash: string | null;
37
+ payer: string | null;
38
+ paid_at: number | null;
39
+ visitor: string | null;
40
+ };
41
+
42
+ const STATUSES: readonly OrderStatus[] = ["open", "paid", "expired", "paid_late"];
43
+
44
+ const orderOf = (r: Row): ShopOrder => ({
45
+ id: r.id,
46
+ player: r.player,
47
+ lookId: r.look_id,
48
+ usd: r.usd,
49
+ rate: Number(r.rate),
50
+ amount: r.amount,
51
+ amountLock: r.amount_lock ?? null,
52
+ status: (STATUSES as readonly string[]).includes(r.status) ? (r.status as OrderStatus) : "expired",
53
+ createdAt: Number(r.created_at),
54
+ expiresAt: Number(r.expires_at),
55
+ holdUntil: Number(r.hold_until),
56
+ hash: r.hash ?? null,
57
+ payer: r.payer ?? null,
58
+ paidAt: r.paid_at === null || r.paid_at === undefined ? null : Number(r.paid_at),
59
+ visitor: r.visitor ?? null,
60
+ });
61
+
62
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
63
+ const changes = (res: unknown): number => Number((res as { meta?: { changes?: number } } | undefined)?.meta?.changes ?? 0);
64
+ const isUnique = (e: unknown) => /UNIQUE/i.test(String((e as Error)?.message ?? e));
65
+ /** D1 takes at most 100 bound values in one statement. */
66
+ const CHUNK = 90;
67
+
68
+ const tableName = (v: string | undefined, fallback: string): string => {
69
+ const name = v ?? fallback;
70
+ if (!NAME.test(name)) throw new Error(`the table name "${name}" must be plain (letters, digits and _)`);
71
+ return name;
72
+ };
73
+
74
+ async function sha256Hex(text: string): Promise<string> {
75
+ const bytes = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
76
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
77
+ }
78
+
79
+ /**
80
+ * True while `key` has made at most `limit` calls in this fixed window, counted in D1 (the table `hits`: one count for the whole game, every location).
81
+ * The game starter's `d1Limit`. Old windows stay until the game deletes them (the starter's cron deletes those older than a day).
82
+ */
83
+ export async function d1Limit(db: D1Like, key: string, limit: number, windowMs: number, now = Date.now(), tables: Pick<ShopTables, "hits"> = {}): Promise<boolean> {
84
+ const hits = tableName(tables.hits, "hits");
85
+ const win = Math.floor(now / windowMs);
86
+ const row = await db.prepare(`INSERT INTO ${hits} (key, win, count) VALUES (?, ?, 1) ON CONFLICT (key, win) DO UPDATE SET count = count + 1 RETURNING count`).bind(key, win).first<{ count: number }>();
87
+ return Number(row?.count ?? 1) <= limit;
88
+ }
89
+
90
+ /** The visitor's IP address as Cloudflare saw it (CF-Connecting-IP); an IPv6 address by its /64 (one home network). "local" when there is none. */
91
+ export function visitorIp(req: Request): string {
92
+ const ip = req.headers.get("CF-Connecting-IP") ?? "local";
93
+ return ip.includes(":") ? ip.split(":").slice(0, 4).join(":") + "::/64" : ip;
94
+ }
95
+
96
+ /**
97
+ * The visitor's key: a hash of a random salt (made once per game and kept in `meta` as "salt") and the IP address, never the address itself. The game
98
+ * starter's `visitorKey`: the same salt row, so the keys stay the same after a switch.
99
+ */
100
+ export async function visitorKey(db: D1Like, req: Request, tables: Pick<ShopTables, "meta"> = {}): Promise<string> {
101
+ const meta = tableName(tables.meta, "meta");
102
+ let row = await db.prepare(`SELECT value FROM ${meta} WHERE key = 'salt'`).first<{ value: string }>();
103
+ if (!row) {
104
+ const fresh = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), (x) => x.toString(16).padStart(2, "0")).join("");
105
+ await db.prepare(`INSERT OR IGNORE INTO ${meta} (key, value, at) VALUES ('salt', ?, ?)`).bind(fresh, Date.now()).run();
106
+ row = await db.prepare(`SELECT value FROM ${meta} WHERE key = 'salt'`).first<{ value: string }>();
107
+ }
108
+ return (await sha256Hex(`${row!.value}|${visitorIp(req)}`)).slice(0, 32);
109
+ }
110
+
111
+ export function createD1Shop(db: D1Like, options: { tables?: ShopTables } = {}): ShopStore {
112
+ const T = { orders: tableName(options.tables?.orders, "orders"), owned: tableName(options.tables?.owned, "owned"), meta: tableName(options.tables?.meta, "meta") };
113
+ const COLS = "id, player, look_id, usd, rate, amount, amount_lock, status, created_at, expires_at, hold_until, hash, payer, paid_at, visitor";
114
+
115
+ async function inChunks(sql: (marks: string) => string, values: readonly string[], col: string): Promise<Set<string>> {
116
+ const out = new Set<string>();
117
+ for (let i = 0; i < values.length; i += CHUNK) {
118
+ const part = values.slice(i, i + CHUNK);
119
+ const rows = (await db.prepare(sql(part.map(() => "?").join(", "))).bind(...part).all<Record<string, string>>()).results;
120
+ for (const r of rows) out.add(r[col]);
121
+ }
122
+ return out;
123
+ }
124
+
125
+ const tidy = async (now: number) => {
126
+ await db.batch([
127
+ db.prepare(`UPDATE ${T.orders} SET status = 'expired' WHERE status = 'open' AND expires_at < ?`).bind(now),
128
+ db.prepare(`UPDATE ${T.orders} SET amount_lock = NULL WHERE amount_lock IS NOT NULL AND hold_until < ?`).bind(now),
129
+ ]);
130
+ };
131
+
132
+ const waiting = async (player: string) =>
133
+ db.prepare(`SELECT COUNT(*) AS n, MAX(hold_until) AS until FROM ${T.orders} WHERE player = ? AND amount_lock IS NOT NULL`).bind(player).first<{ n: number; until: number | null }>();
134
+
135
+ return {
136
+ tidy,
137
+
138
+ heldAmounts: (amounts) => inChunks((m) => `SELECT amount_lock FROM ${T.orders} WHERE amount_lock IN (${m})`, amounts, "amount_lock"),
139
+
140
+ async insert(o) {
141
+ try {
142
+ await db
143
+ .prepare(`INSERT INTO ${T.orders} (id, player, look_id, usd, rate, amount, amount_lock, status, created_at, expires_at, hold_until, visitor) VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?)`)
144
+ .bind(o.id, o.player, o.lookId, o.usd, o.rate, o.amount, o.amount, o.createdAt, o.expiresAt, o.holdUntil, o.visitor)
145
+ .run();
146
+ return true;
147
+ } catch (e) {
148
+ // Another order took this amount since the read. Any other failure stops.
149
+ if (isUnique(e)) return false;
150
+ throw e;
151
+ }
152
+ },
153
+
154
+ async order(id) {
155
+ const r = await db.prepare(`SELECT ${COLS} FROM ${T.orders} WHERE id = ?`).bind(id).first<Row>();
156
+ return r ? orderOf(r) : null;
157
+ },
158
+
159
+ async openOrder(player, lookId, until) {
160
+ const r = await db
161
+ .prepare(`SELECT ${COLS} FROM ${T.orders} WHERE player = ? AND look_id = ? AND status = 'open' AND expires_at > ? ORDER BY created_at DESC LIMIT 1`)
162
+ .bind(player, lookId, until)
163
+ .first<Row>();
164
+ return r ? orderOf(r) : null;
165
+ },
166
+
167
+ async countOpen(player, now) {
168
+ const r = await db.prepare(`SELECT COUNT(*) AS n FROM ${T.orders} WHERE player = ? AND status = 'open' AND expires_at > ?`).bind(player, now).first<{ n: number }>();
169
+ return Number(r?.n ?? 0);
170
+ },
171
+
172
+ async owns(player, lookId) {
173
+ return Boolean(await db.prepare(`SELECT 1 AS one FROM ${T.owned} WHERE player = ? AND look_id = ?`).bind(player, lookId).first());
174
+ },
175
+
176
+ async owned(player) {
177
+ return (await db.prepare(`SELECT look_id FROM ${T.owned} WHERE player = ? ORDER BY granted_at, look_id`).bind(player).all<{ look_id: string }>()).results.map((r) => r.look_id);
178
+ },
179
+
180
+ async held() {
181
+ return (await db.prepare(`SELECT ${COLS} FROM ${T.orders} WHERE amount_lock IS NOT NULL`).all<Row>()).results.map(orderOf);
182
+ },
183
+
184
+ usedHashes: (hashes) => inChunks((m) => `SELECT hash FROM ${T.orders} WHERE hash IN (${m})`, hashes, "hash"),
185
+
186
+ async settle(order, pay, now): Promise<Settled> {
187
+ const status = paidStatus(order, pay.seenMs);
188
+ try {
189
+ const [update] = await db.batch([
190
+ db
191
+ .prepare(`UPDATE ${T.orders} SET status = ?, hash = ?, payer = ?, paid_at = ?, amount_lock = NULL WHERE id = ? AND status IN ('open', 'expired') AND amount_lock IS NOT NULL`)
192
+ .bind(status, pay.hash, pay.payer, pay.seenMs, order.id),
193
+ db
194
+ .prepare(`INSERT OR IGNORE INTO ${T.owned} (player, look_id, order_id, granted_at) SELECT player, look_id, id, ? FROM ${T.orders} WHERE id = ? AND status = 'paid' AND hash = ?`)
195
+ .bind(now, order.id, pay.hash),
196
+ ]);
197
+ return changes(update) === 1 ? status : "taken";
198
+ } catch (e) {
199
+ if (isUnique(e)) return "used";
200
+ throw e;
201
+ }
202
+ },
203
+
204
+ async claimScan(now, gapMs) {
205
+ const r = await db
206
+ .prepare(`INSERT INTO ${T.meta} (key, value, at) VALUES ('scan', '', ?) ON CONFLICT (key) DO UPDATE SET at = excluded.at WHERE ${T.meta}.at <= ?`)
207
+ .bind(now, now - gapMs)
208
+ .run();
209
+ return changes(r) === 1;
210
+ },
211
+
212
+ async readRate() {
213
+ const r = await db.prepare(`SELECT value, at FROM ${T.meta} WHERE key = 'rate'`).first<{ value: string; at: number }>();
214
+ return r ? parseRate(r.value, Number(r.at)) : null;
215
+ },
216
+
217
+ async saveRate(rate) {
218
+ await db
219
+ .prepare(`INSERT INTO ${T.meta} (key, value, at) VALUES ('rate', ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at`)
220
+ .bind(JSON.stringify({ usd: rate.usd, sources: rate.sources }), rate.at)
221
+ .run();
222
+ },
223
+
224
+ async forget(player, now): Promise<ForgetResult> {
225
+ await tidy(now);
226
+ const w = await waiting(player);
227
+ if (Number(w?.n ?? 0) > 0) return { deleted: 0, kept: 0, waitUntil: Number(w!.until) };
228
+ // One batch, and each statement holds the money gate again: an order made meanwhile stops every one of them.
229
+ const gate = `NOT EXISTS (SELECT 1 FROM ${T.orders} WHERE player = ?1 AND amount_lock IS NOT NULL)`;
230
+ const done = await db.batch([
231
+ db.prepare(`DELETE FROM ${T.owned} WHERE player = ?1 AND ${gate}`).bind(player),
232
+ db.prepare(`DELETE FROM ${T.orders} WHERE player = ?1 AND hash IS NULL AND amount_lock IS NULL AND ${gate}`).bind(player),
233
+ // A paid order keeps no payer; a paid-late one keeps it: the creator sends that payment back by hand.
234
+ db.prepare(`UPDATE ${T.orders} SET payer = ?2 WHERE payer = ?1 AND status = 'paid' AND ${gate}`).bind(player, FORGOTTEN),
235
+ db.prepare(`UPDATE ${T.orders} SET player = ?2, visitor = NULL WHERE player = ?1 AND hash IS NOT NULL AND ${gate}`).bind(player, FORGOTTEN),
236
+ ]);
237
+ const [owned, orders, , kept] = done.map(changes);
238
+ if (owned + orders + kept === 0) {
239
+ const again = await waiting(player);
240
+ if (Number(again?.n ?? 0) > 0) return { deleted: 0, kept: 0, waitUntil: Number(again!.until) };
241
+ }
242
+ return { deleted: owned + orders, kept, waitUntil: null };
243
+ },
244
+ };
245
+ }