@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,52 @@
1
+ // Dollars per XNO (the game starter's src/worker/price.ts): the median of public price feeds that need no key, so one wrong feed cannot move the price.
2
+ // The answer is kept 5 minutes (in the store: `readRate`, `saveRate`). When every feed fails, the last good price is used for up to 6 hours; after that,
3
+ // new orders wait ("Prices are not available") and nothing is sold at a guessed price.
4
+ //
5
+ // CoinGecko's API terms ask for the words "Powered by CoinGecko": the pay screen shows them.
6
+
7
+ import { MAX_RATE, MIN_RATE } from "./amount.js";
8
+ import type { Fetch } from "./nodes.js";
9
+ import type { ShopStore } from "./store.js";
10
+
11
+ export type PriceFeed = { name: string; url: string; read: (j: any) => unknown };
12
+
13
+ export const PRICE_FEEDS: readonly PriceFeed[] = [
14
+ { name: "CoinGecko", url: "https://api.coingecko.com/api/v3/simple/price?ids=nano&vs_currencies=usd", read: (j) => j?.nano?.usd },
15
+ { name: "Kraken", url: "https://api.kraken.com/0/public/Ticker?pair=NANOUSD", read: (j) => (Object.values(j?.result ?? {})[0] as any)?.c?.[0] },
16
+ { name: "KuCoin", url: "https://api.kucoin.com/api/v1/market/orderbook/level1?symbol=XNO-USDT", read: (j) => j?.data?.price },
17
+ ];
18
+
19
+ export const RATE_FRESH_MS = 5 * 60_000;
20
+ export const RATE_LAST_GOOD_MS = 6 * 60 * 60_000;
21
+
22
+ export type Rate = { usd: number; at: number; sources: string[]; stale: boolean };
23
+
24
+ /** Asks every feed at once and returns the median of the believable answers, or null. */
25
+ export async function readFeeds(fetchFn: Fetch, feeds: readonly PriceFeed[] = PRICE_FEEDS, timeoutMs = 4000): Promise<{ usd: number; sources: string[] } | null> {
26
+ const answers = await Promise.allSettled(
27
+ feeds.map(async (f) => {
28
+ const res = await fetchFn(f.url, { headers: { Accept: "application/json", "User-Agent": "nano-game-starter" }, signal: AbortSignal.timeout(timeoutMs) });
29
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
30
+ return Number(f.read(await res.json()));
31
+ }),
32
+ );
33
+ const good = answers
34
+ .map((a, i) => ({ name: feeds[i].name, v: a.status === "fulfilled" ? a.value : NaN }))
35
+ .filter((x) => Number.isFinite(x.v) && x.v >= MIN_RATE && x.v <= MAX_RATE)
36
+ .sort((a, b) => a.v - b.v);
37
+ if (!good.length) return null;
38
+ return { usd: good[Math.floor(good.length / 2)].v, sources: good.map((g) => g.name) };
39
+ }
40
+
41
+ /** Dollars per XNO now, or null when no believable price is known. */
42
+ export async function usdPerXno(store: Pick<ShopStore, "readRate" | "saveRate">, fetchFn: Fetch, now = Date.now(), feeds: readonly PriceFeed[] = PRICE_FEEDS): Promise<Rate | null> {
43
+ const last = await store.readRate();
44
+ if (last && now - last.at < RATE_FRESH_MS) return { usd: last.usd, sources: last.sources, at: last.at, stale: false };
45
+ const fresh = await readFeeds(fetchFn, feeds);
46
+ if (fresh) {
47
+ await store.saveRate({ ...fresh, at: now });
48
+ return { ...fresh, at: now, stale: false };
49
+ }
50
+ if (last && now - last.at < RATE_LAST_GOOD_MS) return { usd: last.usd, sources: last.sources, at: last.at, stale: true };
51
+ return null;
52
+ }
@@ -0,0 +1,90 @@
1
+ // The payment scan (the game starter's src/worker/sweep.ts): one look at the creator's address settles every held order at once. It READS the ledger and
2
+ // writes only the store: no key, no send. (The starter calls it "sweep"; it moves no money, unlike @nano133/checkout's sweep.)
3
+ //
4
+ // A player's pay screen asks for it every 5 seconds, the admin page on "Check payments now", and a cron every 10 minutes (for a player who paid and closed
5
+ // the page). Node load stays flat however many players pay: the whole game makes at most one scan every SCAN_GAP_MS, and none when no order is held.
6
+
7
+ import { candidates, checkSend } from "./match.js";
8
+ import { rpc, type NanoNode, type RpcOptions } from "./nodes.js";
9
+ import { smallestAmount } from "./orders.js";
10
+ import type { ShopOrder, ShopStore } from "./store.js";
11
+
12
+ export const SCAN_GAP_MS = 4000;
13
+ /** The most entries one RPC read asks for. */
14
+ const COUNT = "50";
15
+
16
+ export type ScanResult = { scanned: boolean; settled: { id: string; status: string }[]; error?: string };
17
+
18
+ /** A confirmed send never changes: its answer is kept for an hour (in this copy of the server). */
19
+ const blockCache = new Map<string, { info: Record<string, any>; until: number }>();
20
+
21
+ async function blockInfo(nodes: readonly NanoNode[], hash: string, o: RpcOptions, now: number) {
22
+ const hit = blockCache.get(hash);
23
+ if (hit && hit.until > now) return hit.info;
24
+ const { answer } = await rpc(nodes, { action: "block_info", hash, json_block: "true" }, o);
25
+ if (answer && (answer.confirmed === "true" || answer.confirmed === true)) {
26
+ if (blockCache.size > 500) blockCache.clear();
27
+ blockCache.set(hash, { info: answer, until: now + 3_600_000 });
28
+ }
29
+ return answer;
30
+ }
31
+
32
+ export type ScanOptions = {
33
+ /** The creator's address (a checked one: `payeeOf`). Null: no scan. */
34
+ address: string | null;
35
+ /** The nodes to ask (`nodesFor`). */
36
+ nodes: readonly NanoNode[];
37
+ /** The time now in ms (default Date.now()). */
38
+ at?: number;
39
+ rpc?: RpcOptions;
40
+ };
41
+
42
+ /** Scans the creator's address once and settles the held orders it pays. Fails closed: when the nodes cannot be read, nothing is credited. */
43
+ export async function scanPayments(store: ShopStore, opts: ScanOptions): Promise<ScanResult> {
44
+ const now = opts.at ?? Date.now();
45
+ const o: RpcOptions = opts.rpc ?? {};
46
+ await store.tidy(now);
47
+ const address = opts.address;
48
+ if (!address) return { scanned: false, settled: [] };
49
+ const held = await store.held();
50
+ if (!held.length) return { scanned: false, settled: [] };
51
+ if (!(await store.claimScan(now, SCAN_GAP_MS))) return { scanned: false, settled: [] };
52
+
53
+ const settled: ScanResult["settled"] = [];
54
+ try {
55
+ // Only amounts a held order asks for: small sends ("dust") cannot push a payment out of the list.
56
+ const threshold = smallestAmount(held)!;
57
+ const receivable = await rpc(opts.nodes, { action: "receivable", account: address, count: COUNT, threshold, source: "true" }, o);
58
+ // A new address has no history yet: the node answers "Account not found" (null here).
59
+ const history = await rpc(opts.nodes, { action: "account_history", account: address, count: COUNT, raw: "true" }, o);
60
+ const byAmount = new Map<string, ShopOrder>(held.map((h) => [h.amount, h]));
61
+ const found = candidates(receivable.answer, history.answer, new Set(byAmount.keys()));
62
+ if (!found.size) return { scanned: true, settled };
63
+
64
+ // A payment that already paid (or was recorded for) an order never counts again.
65
+ const used = await store.usedHashes([...found.keys()]);
66
+ for (const [hash, amount] of found) {
67
+ if (used.has(hash)) continue;
68
+ const order = byAmount.get(amount);
69
+ if (!order) continue;
70
+ const info = await blockInfo(opts.nodes, hash, o, now);
71
+ if (!info) continue;
72
+ const verdict = checkSend(info, address, order.amount, order.createdAt);
73
+ if (verdict.kind !== "paid") continue;
74
+ const result = await store.settle(order, { hash, payer: verdict.payer, seenMs: verdict.seenMs }, now);
75
+ if (result === "paid" || result === "paid_late") {
76
+ settled.push({ id: order.id, status: result });
77
+ byAmount.delete(amount);
78
+ }
79
+ }
80
+ return { scanned: true, settled };
81
+ } catch (e) {
82
+ // Fail closed: nothing is credited when the nodes cannot be read. The next scan tries again.
83
+ return { scanned: false, settled, error: e instanceof Error ? e.message : String(e) };
84
+ }
85
+ }
86
+
87
+ /** For tests: forget the kept send blocks. */
88
+ export function forgetBlocks(): void {
89
+ blockCache.clear();
90
+ }
@@ -0,0 +1,50 @@
1
+ // The shop's needs in a game's Content Security Policy, as LISTS that a game merges into its own policy. Never a whole file: the game keeps every
2
+ // source it has (its own frames, its own scripts), and adds these.
3
+ //
4
+ // import { SHOP_CSP, mergeCsp } from "@nano133/nanoterra/shop";
5
+ // const policy = mergeCsp(MY_CSP, SHOP_CSP);
6
+ //
7
+ // The pay sheet draws its QR code on a canvas (no picture from another site), and the wallet key is a plain "nano:" link (a CSP does not limit links).
8
+ // Only Turnstile (the person check, when the game turns it on) needs another origin: its script and its frame.
9
+
10
+ /** Turnstile (Cloudflare's free check that a visitor is a person) loads from here. */
11
+ export const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
12
+
13
+ /** The sources the shop adds, by directive. */
14
+ export const SHOP_CSP: Readonly<Record<string, readonly string[]>> = {
15
+ "script-src": [TURNSTILE_ORIGIN],
16
+ "frame-src": [TURNSTILE_ORIGIN],
17
+ "connect-src": ["'self'"],
18
+ };
19
+
20
+ /**
21
+ * A policy with more sources: `policy` is a CSP text ("default-src 'none'; script-src 'self'") or a map of directives to sources, and each of `more` adds
22
+ * its sources to the same directive (a directive that is not there yet is added). A source is never written twice. The order of the policy is kept.
23
+ * A directive that has 'none' and gets a source loses the 'none' (the two cannot stand together).
24
+ */
25
+ export function mergeCsp(policy: string | Readonly<Record<string, readonly string[]>>, ...more: Readonly<Record<string, readonly string[]>>[]): string {
26
+ const map = new Map<string, string[]>();
27
+ const add = (directive: string, sources: readonly string[]) => {
28
+ const name = directive.trim().toLowerCase();
29
+ if (!/^[a-z-]+$/.test(name)) throw new Error(`not a CSP directive: ${directive}`);
30
+ const list = map.get(name) ?? [];
31
+ for (const s of sources) {
32
+ if (!s || /[;,\s]/.test(s)) throw new Error(`not a CSP source: ${s}`);
33
+ if (!list.includes(s)) list.push(s);
34
+ }
35
+ map.set(name, list);
36
+ };
37
+ if (typeof policy === "string") {
38
+ for (const part of policy.split(";")) {
39
+ const [name, ...sources] = part.trim().split(/\s+/).filter(Boolean);
40
+ if (name) add(name, sources);
41
+ }
42
+ } else for (const [k, v] of Object.entries(policy)) add(k, v);
43
+ for (const m of more) for (const [k, v] of Object.entries(m)) add(k, v);
44
+ return [...map]
45
+ .map(([name, sources]) => {
46
+ const list = sources.length > 1 ? sources.filter((s) => s !== "'none'") : sources;
47
+ return list.length ? `${name} ${list.join(" ")}` : name;
48
+ })
49
+ .join("; ");
50
+ }
@@ -0,0 +1,99 @@
1
+ // The shop's records and the store contract. The package has no database: a game gives a store (memory, D1 or Firestore here, or its own that passes
2
+ // "@nano133/nanoterra/shop/conformance"). The rules of money live in the store's few writes, so each one says exactly what it must do in ONE step.
3
+
4
+ export type OrderStatus = "open" | "paid" | "expired" | "paid_late";
5
+
6
+ /** One order: a player asked to buy a look. Amounts are raw, as text; times are ms since 1970. */
7
+ export type ShopOrder = {
8
+ /** 24 hex characters. */
9
+ id: string;
10
+ /** The signed-in player who made it (the game's player id: the starter's is the Nano address). Only this player sees the order. */
11
+ player: string;
12
+ lookId: string;
13
+ /** The price in US dollars when the order was made: "0.25". */
14
+ usd: string;
15
+ /** Dollars per XNO that turned the price into XNO. */
16
+ rate: number;
17
+ /** The exact amount to pay: a clean amount (at most 4 decimals of XNO). */
18
+ amount: string;
19
+ /**
20
+ * The reservation: equal to `amount` while a payment of it may still be matched to this order, null after. No two orders ever hold the same amount
21
+ * (the store's unique rule), so one payment can only match one order.
22
+ */
23
+ amountLock: string | null;
24
+ status: OrderStatus;
25
+ createdAt: number;
26
+ /** The order takes a payment until then. */
27
+ expiresAt: number;
28
+ /** The amount stays reserved until then (a late payment is still seen, and never counts for a newer order). */
29
+ holdUntil: number;
30
+ /** The paying send block: one payment pays one order, once (the store's unique rule). */
31
+ hash: string | null;
32
+ /** The account that sent it. */
33
+ payer: string | null;
34
+ /** When the node first saw that send. */
35
+ paidAt: number | null;
36
+ /** A keyed hash of the buyer's IP address, for limits (never the address), or null. */
37
+ visitor: string | null;
38
+ };
39
+
40
+ /** What `settle` did. "used": that payment already paid another order. "taken": this order was settled meanwhile (or its amount is no longer held). */
41
+ export type Settled = "paid" | "paid_late" | "used" | "taken";
42
+
43
+ /** A payment the scan found: the send's hash, its account and when the node first saw it. */
44
+ export type Payment = { hash: string; payer: string; seenMs: number };
45
+
46
+ /** The price of XNO the shop keeps (the median of the feeds) and when it read it. */
47
+ export type KeptRate = { usd: number; sources: string[]; at: number };
48
+
49
+ /**
50
+ * What `forget` did. `waitUntil`: the player has an order whose amount is still held (a payment may still come for it), so NOTHING was deleted; try again
51
+ * after that time. Else `deleted` rows went (owned looks, and orders that were never paid), and `kept` paid orders stay as the creator's sales record,
52
+ * with the player (and the payer of a paid order) replaced by FORGOTTEN.
53
+ */
54
+ export type ForgetResult = { deleted: number; kept: number; waitUntil: number | null };
55
+
56
+ /** The stand-in for a player who used "Delete my data", in the orders that stay as the creator's sales record. */
57
+ export const FORGOTTEN = "deleted";
58
+
59
+ /**
60
+ * The shop's database. An adapter writes ONLY orders, owned looks, the kept price and the scan's claim (and their locks). Each call is ONE step: a
61
+ * transaction, a batch, or a statement that holds its own check.
62
+ */
63
+ export interface ShopStore {
64
+ /** Marks the open orders past `expiresAt` "expired", and frees the amounts whose `holdUntil` is past (the amount lock goes, the order stays). */
65
+ tidy(now: number): Promise<void>;
66
+ /** Which of `amounts` an order holds now. */
67
+ heldAmounts(amounts: readonly string[]): Promise<Set<string>>;
68
+ /**
69
+ * Writes a new order that holds its amount (`amountLock` = `amount`). False, and nothing written, when another order holds that amount now: the
70
+ * unique rule, checked in the same step as the write. Any other failure throws.
71
+ */
72
+ insert(order: ShopOrder): Promise<boolean>;
73
+ order(id: string): Promise<ShopOrder | null>;
74
+ /** The newest open order of this player for this look that still runs at `until`, or null. */
75
+ openOrder(player: string, lookId: string, until: number): Promise<ShopOrder | null>;
76
+ /** How many open orders this player has that still run at `now`. */
77
+ countOpen(player: string, now: number): Promise<number>;
78
+ /** Whether a paid order gave this player this look. */
79
+ owns(player: string, lookId: string): Promise<boolean>;
80
+ /** The ids of every look that paid orders gave this player (free looks are everyone's and are not stored). */
81
+ owned(player: string): Promise<string[]>;
82
+ /** The orders whose amount is held: the only ones a payment can be matched to. */
83
+ held(): Promise<ShopOrder[]>;
84
+ /** Which of `hashes` already paid (or were recorded for) an order. */
85
+ usedHashes(hashes: readonly string[]): Promise<Set<string>>;
86
+ /**
87
+ * Records a payment for a held order, exactly once, in ONE step: the order becomes "paid" (sent while it was open: `pay.seenMs` at most
88
+ * CLOCK_SLACK_MS after `expiresAt`) and the look is given to the ORDER's player (once: one owned row per player and look), or "paid_late" (no look);
89
+ * either way the hash is recorded and the amount freed. "taken" when the order is not open or expired with its amount held (it was settled meanwhile,
90
+ * or its hold ended), else "used" when the hash paid another order. Either way nothing changes.
91
+ */
92
+ settle(order: ShopOrder, pay: Payment, now: number): Promise<Settled>;
93
+ /** Claims the next payment scan: true for exactly one caller in each `gapMs`. */
94
+ claimScan(now: number, gapMs: number): Promise<boolean>;
95
+ readRate(): Promise<KeptRate | null>;
96
+ saveRate(rate: KeptRate): Promise<void>;
97
+ /** "Delete my data" for the shop (see ForgetResult). */
98
+ forget(player: string, now: number): Promise<ForgetResult>;
99
+ }
@@ -0,0 +1,107 @@
1
+ // The shop for a Cloudflare Worker with D1, set up from the game starter's own settings (wrangler.jsonc "vars" and secrets). One call gives the routes,
2
+ // the cron's payment scan, what the page and the admin need to know, and the store:
3
+ //
4
+ // import { createShop } from "@nano133/nanoterra/shop/worker";
5
+ // import looks from "../../looks.json";
6
+ // const shop = createShop(env, { catalogue: looks, player: async (req) => (await sessionOf(env.DB, req))?.address ?? null });
7
+ // const answer = await shop.handle(request); // null when it is not a shop route
8
+ // ...
9
+ // async scheduled(_e, env, ctx) { ctx.waitUntil(createShop(env, opts).scan()); } // the */10 cron
10
+ //
11
+ // The settings (the starter's names): RECEIVE_ADDRESS (the creator's address; the example or a wrong checksum keeps test mode), TEST_MODE ("on" keeps
12
+ // test mode with a real address), NODE_API_KEY (secret, optional: Nanswap Nodes first), NANO_NODES (optional: the game's own nodes), TURNSTILE_SITE_KEY
13
+ // and the secret TURNSTILE_SECRET (both, or no person check). "none", "skip", "off" and "-" mean "not set". The store is D1 on the starter's tables
14
+ // (`env.DB`; the migration is "@nano133/nanoterra/shop/d1.sql"). The player's sign-in is the game's own (`player`).
15
+
16
+ import type { D1Like } from "../admin/adapters/d1.js";
17
+ import { createD1Shop, d1Limit, visitorKey, type ShopTables } from "./adapters/d1.js";
18
+ import { createShopHandler, SHOP_LIMITS, type ShopHandler, type ShopHandlerOptions } from "./handler.js";
19
+ import { nodesFor, settingValue, type Fetch } from "./nodes.js";
20
+ import type { ShopStore } from "./store.js";
21
+
22
+ /** The settings the shop reads. Every one is optional except the database. */
23
+ export type ShopEnv = {
24
+ DB: D1Like;
25
+ RECEIVE_ADDRESS?: string;
26
+ TEST_MODE?: string;
27
+ NODE_API_KEY?: string;
28
+ NANO_NODES?: string;
29
+ TURNSTILE_SITE_KEY?: string;
30
+ TURNSTILE_SECRET?: string;
31
+ };
32
+
33
+ export type ShopOptions = Pick<ShopHandlerOptions, "catalogue" | "player" | "base" | "now" | "onError" | "rate" | "rpc"> & {
34
+ /** The D1 table names (default: the starter's). */
35
+ tables?: ShopTables;
36
+ /** The `fetch` for Turnstile's check (a test gives its own). */
37
+ fetch?: Fetch;
38
+ };
39
+
40
+ const VERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
41
+
42
+ /** "on", "true", "1" or "yes" (any case). */
43
+ const isOn = (v?: string) => /^(on|true|1|yes)$/i.test((v ?? "").trim());
44
+
45
+ /** Whether Turnstile is on: the site key AND the secret are both set. */
46
+ export const turnstileOn = (env: Pick<ShopEnv, "TURNSTILE_SITE_KEY" | "TURNSTILE_SECRET">): boolean => Boolean(settingValue(env.TURNSTILE_SECRET) && settingValue(env.TURNSTILE_SITE_KEY));
47
+
48
+ /** True when the person check passes, or when Turnstile is off (the starter's turnstile.ts). The secret is sent to Cloudflare only. */
49
+ export async function turnstileCheck(env: Pick<ShopEnv, "TURNSTILE_SITE_KEY" | "TURNSTILE_SECRET">, token: unknown, ip: string | null, fetchFn: Fetch = (i, init) => fetch(i, init)): Promise<boolean> {
50
+ if (!turnstileOn(env)) return true;
51
+ if (typeof token !== "string" || !token || token.length > 2048) return false;
52
+ const form = new FormData();
53
+ form.append("secret", settingValue(env.TURNSTILE_SECRET)!);
54
+ form.append("response", token);
55
+ if (ip) form.append("remoteip", ip);
56
+ try {
57
+ const res = await fetchFn(VERIFY, { method: "POST", body: form, signal: AbortSignal.timeout(5000) });
58
+ const out = (await res.json()) as { success?: boolean };
59
+ return out.success === true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ export type Shop = ShopHandler & {
66
+ store: ShopStore;
67
+ /** What the game's page needs (the starter's GET /api/config gives these two): whether pay screens are in test mode, and Turnstile's site key or null. */
68
+ config(): { testMode: boolean; turnstileSiteKey: string | null };
69
+ /** For the admin's Status card: the address, test mode, Turnstile, the nodes' names and plain sentences about what is still off. */
70
+ status(): { address: string | null; testMode: boolean; turnstile: boolean; nodes: string[]; warnings: string[] };
71
+ };
72
+
73
+ export function createShop(env: ShopEnv, options: ShopOptions): Shop {
74
+ const store = createD1Shop(env.DB, { tables: options.tables });
75
+ const nodes = nodesFor(env);
76
+ const turnstile = turnstileOn(env);
77
+ const handler = createShopHandler({
78
+ ...options,
79
+ store,
80
+ payee: env.RECEIVE_ADDRESS ?? null,
81
+ testMode: isOn(env.TEST_MODE),
82
+ nodes,
83
+ visitor: (req) => visitorKey(env.DB, req, { meta: options.tables?.meta }),
84
+ // Two layers, as the starter: the game's router counts every call in memory; this one counts new orders for the whole game, in D1 (`hits`).
85
+ limit: (key, limit, windowMs, at) => d1Limit(env.DB, key, limit, windowMs, at),
86
+ personCheck: turnstile ? (token, req) => turnstileCheck(env, token, req.headers.get("CF-Connecting-IP"), options.fetch) : undefined,
87
+ });
88
+ return {
89
+ ...handler,
90
+ store,
91
+ config: () => ({ testMode: handler.testMode, turnstileSiteKey: turnstile ? settingValue(env.TURNSTILE_SITE_KEY)! : null }),
92
+ status() {
93
+ const warnings: string[] = [];
94
+ if (handler.testMode) warnings.push(handler.payee ? "Test mode is on (TEST_MODE). Pay screens say TEST MODE: DO NOT PAY." : "Test mode: no receive address is set.");
95
+ if (!turnstile)
96
+ warnings.push(
97
+ settingValue(env.TURNSTILE_SECRET) || settingValue(env.TURNSTILE_SITE_KEY)
98
+ ? "Turnstile is OFF: set both TURNSTILE_SITE_KEY and the secret TURNSTILE_SECRET."
99
+ : "Turnstile is OFF: sign-in and orders are not checked for bots. Add a free Turnstile widget (see README).",
100
+ );
101
+ if (!settingValue(env.NODE_API_KEY)) warnings.push("No NODE_API_KEY: payments are checked on public Nano nodes only, which promise no uptime.");
102
+ return { address: handler.payee, testMode: handler.testMode, turnstile, nodes: nodes.map((n) => n.name), warnings };
103
+ },
104
+ };
105
+ }
106
+
107
+ export { SHOP_LIMITS };