@nano133/nanoterra 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/LICENSE +3 -0
  3. package/README.md +363 -25
  4. package/ai/SPEC.md +65 -3
  5. package/dist/admin/panel/dom.d.ts +2 -0
  6. package/dist/admin/panel/dom.js +6 -0
  7. package/dist/admin/panel/mount.d.ts +3 -0
  8. package/dist/admin/panel/mount.js +4 -2
  9. package/dist/emotes/panel/mount.d.ts +5 -0
  10. package/dist/emotes/panel/mount.js +7 -3
  11. package/dist/friends/panel/mount.d.ts +5 -0
  12. package/dist/friends/panel/mount.js +5 -2
  13. package/dist/options/panel/mount.d.ts +5 -0
  14. package/dist/options/panel/mount.js +5 -3
  15. package/dist/player-signin/adapters/d1.d.ts +10 -0
  16. package/dist/player-signin/adapters/d1.js +108 -0
  17. package/dist/player-signin/adapters/firestore.d.ts +45 -0
  18. package/dist/player-signin/adapters/firestore.js +168 -0
  19. package/dist/player-signin/conformance.d.ts +11 -0
  20. package/dist/player-signin/conformance.js +160 -0
  21. package/dist/player-signin/handler.d.ts +64 -0
  22. package/dist/player-signin/handler.js +364 -0
  23. package/dist/player-signin/host.d.ts +4 -0
  24. package/dist/player-signin/host.js +10 -0
  25. package/dist/player-signin/index.d.ts +9 -0
  26. package/dist/player-signin/index.js +15 -0
  27. package/dist/player-signin/memory.d.ts +15 -0
  28. package/dist/player-signin/memory.js +101 -0
  29. package/dist/player-signin/panel/index.d.ts +9 -0
  30. package/dist/player-signin/panel/index.js +7 -0
  31. package/dist/player-signin/panel/memo.d.ts +12 -0
  32. package/dist/player-signin/panel/memo.js +41 -0
  33. package/dist/player-signin/panel/mount.d.ts +21 -0
  34. package/dist/player-signin/panel/mount.js +105 -0
  35. package/dist/player-signin/panel/view.d.ts +45 -0
  36. package/dist/player-signin/panel/view.js +360 -0
  37. package/dist/player-signin/panel/words.d.ts +8 -0
  38. package/dist/player-signin/panel/words.js +26 -0
  39. package/dist/player-signin/scheme-check.d.ts +3 -0
  40. package/dist/player-signin/scheme-check.js +14 -0
  41. package/dist/player-signin/scheme.d.ts +13 -0
  42. package/dist/player-signin/scheme.js +28 -0
  43. package/dist/player-signin/types.d.ts +116 -0
  44. package/dist/player-signin/types.js +33 -0
  45. package/dist/shop/adapters/d1.d.ts +25 -0
  46. package/dist/shop/adapters/d1.js +200 -0
  47. package/dist/shop/adapters/firestore.d.ts +53 -0
  48. package/dist/shop/adapters/firestore.js +245 -0
  49. package/dist/shop/address.d.ts +14 -0
  50. package/dist/shop/address.js +104 -0
  51. package/dist/shop/amount.d.ts +26 -0
  52. package/dist/shop/amount.js +59 -0
  53. package/dist/shop/conformance.d.ts +13 -0
  54. package/dist/shop/conformance.js +203 -0
  55. package/dist/shop/handler.d.ts +76 -0
  56. package/dist/shop/handler.js +156 -0
  57. package/dist/shop/index.d.ts +19 -0
  58. package/dist/shop/index.js +21 -0
  59. package/dist/shop/match.d.ts +23 -0
  60. package/dist/shop/match.js +58 -0
  61. package/dist/shop/memory.d.ts +13 -0
  62. package/dist/shop/memory.js +106 -0
  63. package/dist/shop/nodes.d.ts +47 -0
  64. package/dist/shop/nodes.js +106 -0
  65. package/dist/shop/orders.d.ts +47 -0
  66. package/dist/shop/orders.js +82 -0
  67. package/dist/shop/panel/index.d.ts +7 -0
  68. package/dist/shop/panel/index.js +11 -0
  69. package/dist/shop/panel/looks.d.ts +30 -0
  70. package/dist/shop/panel/looks.js +61 -0
  71. package/dist/shop/panel/mount.d.ts +52 -0
  72. package/dist/shop/panel/mount.js +194 -0
  73. package/dist/shop/panel/pay.d.ts +24 -0
  74. package/dist/shop/panel/pay.js +115 -0
  75. package/dist/shop/panel/qr.d.ts +9 -0
  76. package/dist/shop/panel/qr.js +281 -0
  77. package/dist/shop/price.d.ts +23 -0
  78. package/dist/shop/price.js +43 -0
  79. package/dist/shop/scan.d.ts +24 -0
  80. package/dist/shop/scan.js +80 -0
  81. package/dist/shop/security.d.ts +10 -0
  82. package/dist/shop/security.js +56 -0
  83. package/dist/shop/store.d.ts +101 -0
  84. package/dist/shop/store.js +4 -0
  85. package/dist/shop/worker.d.ts +43 -0
  86. package/dist/shop/worker.js +76 -0
  87. package/package.json +21 -6
  88. package/src/admin/panel/dom.ts +6 -0
  89. package/src/admin/panel/mount.ts +7 -2
  90. package/src/emotes/panel/mount.ts +9 -2
  91. package/src/friends/panel/mount.ts +8 -2
  92. package/src/options/panel/mount.ts +8 -3
  93. package/src/player-signin/adapters/d1.sql +45 -0
  94. package/src/player-signin/adapters/d1.ts +120 -0
  95. package/src/player-signin/adapters/firestore.ts +194 -0
  96. package/src/player-signin/conformance.ts +184 -0
  97. package/src/player-signin/handler.ts +404 -0
  98. package/src/player-signin/host.ts +12 -0
  99. package/src/player-signin/index.ts +20 -0
  100. package/src/player-signin/memory.ts +94 -0
  101. package/src/player-signin/panel/index.ts +12 -0
  102. package/src/player-signin/panel/memo.ts +49 -0
  103. package/src/player-signin/panel/mount.ts +127 -0
  104. package/src/player-signin/panel/style.css +473 -0
  105. package/src/player-signin/panel/view.ts +428 -0
  106. package/src/player-signin/panel/words.ts +32 -0
  107. package/src/player-signin/scheme-check.ts +14 -0
  108. package/src/player-signin/scheme.ts +32 -0
  109. package/src/player-signin/types.ts +103 -0
  110. package/src/shop/adapters/d1.sql +52 -0
  111. package/src/shop/adapters/d1.ts +245 -0
  112. package/src/shop/adapters/firestore.ts +281 -0
  113. package/src/shop/address.ts +102 -0
  114. package/src/shop/amount.ts +66 -0
  115. package/src/shop/conformance.ts +232 -0
  116. package/src/shop/handler.ts +202 -0
  117. package/src/shop/index.ts +30 -0
  118. package/src/shop/match.ts +63 -0
  119. package/src/shop/memory.ts +102 -0
  120. package/src/shop/nodes.ts +115 -0
  121. package/src/shop/orders.ts +93 -0
  122. package/src/shop/panel/index.ts +15 -0
  123. package/src/shop/panel/looks.ts +106 -0
  124. package/src/shop/panel/mount.ts +241 -0
  125. package/src/shop/panel/pay.ts +171 -0
  126. package/src/shop/panel/qr.ts +256 -0
  127. package/src/shop/panel/style.css +534 -0
  128. package/src/shop/price.ts +52 -0
  129. package/src/shop/scan.ts +90 -0
  130. package/src/shop/security.ts +50 -0
  131. package/src/shop/store.ts +99 -0
  132. package/src/shop/worker.ts +107 -0
@@ -0,0 +1,43 @@
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
+ import { MAX_RATE, MIN_RATE } from "./amount.js";
7
+ export const PRICE_FEEDS = [
8
+ { name: "CoinGecko", url: "https://api.coingecko.com/api/v3/simple/price?ids=nano&vs_currencies=usd", read: (j) => j?.nano?.usd },
9
+ { name: "Kraken", url: "https://api.kraken.com/0/public/Ticker?pair=NANOUSD", read: (j) => Object.values(j?.result ?? {})[0]?.c?.[0] },
10
+ { name: "KuCoin", url: "https://api.kucoin.com/api/v1/market/orderbook/level1?symbol=XNO-USDT", read: (j) => j?.data?.price },
11
+ ];
12
+ export const RATE_FRESH_MS = 5 * 60_000;
13
+ export const RATE_LAST_GOOD_MS = 6 * 60 * 60_000;
14
+ /** Asks every feed at once and returns the median of the believable answers, or null. */
15
+ export async function readFeeds(fetchFn, feeds = PRICE_FEEDS, timeoutMs = 4000) {
16
+ const answers = await Promise.allSettled(feeds.map(async (f) => {
17
+ const res = await fetchFn(f.url, { headers: { Accept: "application/json", "User-Agent": "nano-game-starter" }, signal: AbortSignal.timeout(timeoutMs) });
18
+ if (!res.ok)
19
+ throw new Error(`HTTP ${res.status}`);
20
+ return Number(f.read(await res.json()));
21
+ }));
22
+ const good = answers
23
+ .map((a, i) => ({ name: feeds[i].name, v: a.status === "fulfilled" ? a.value : NaN }))
24
+ .filter((x) => Number.isFinite(x.v) && x.v >= MIN_RATE && x.v <= MAX_RATE)
25
+ .sort((a, b) => a.v - b.v);
26
+ if (!good.length)
27
+ return null;
28
+ return { usd: good[Math.floor(good.length / 2)].v, sources: good.map((g) => g.name) };
29
+ }
30
+ /** Dollars per XNO now, or null when no believable price is known. */
31
+ export async function usdPerXno(store, fetchFn, now = Date.now(), feeds = PRICE_FEEDS) {
32
+ const last = await store.readRate();
33
+ if (last && now - last.at < RATE_FRESH_MS)
34
+ return { usd: last.usd, sources: last.sources, at: last.at, stale: false };
35
+ const fresh = await readFeeds(fetchFn, feeds);
36
+ if (fresh) {
37
+ await store.saveRate({ ...fresh, at: now });
38
+ return { ...fresh, at: now, stale: false };
39
+ }
40
+ if (last && now - last.at < RATE_LAST_GOOD_MS)
41
+ return { usd: last.usd, sources: last.sources, at: last.at, stale: true };
42
+ return null;
43
+ }
@@ -0,0 +1,24 @@
1
+ import { type NanoNode, type RpcOptions } from "./nodes.js";
2
+ import type { ShopStore } from "./store.js";
3
+ export declare const SCAN_GAP_MS = 4000;
4
+ export type ScanResult = {
5
+ scanned: boolean;
6
+ settled: {
7
+ id: string;
8
+ status: string;
9
+ }[];
10
+ error?: string;
11
+ };
12
+ export type ScanOptions = {
13
+ /** The creator's address (a checked one: `payeeOf`). Null: no scan. */
14
+ address: string | null;
15
+ /** The nodes to ask (`nodesFor`). */
16
+ nodes: readonly NanoNode[];
17
+ /** The time now in ms (default Date.now()). */
18
+ at?: number;
19
+ rpc?: RpcOptions;
20
+ };
21
+ /** Scans the creator's address once and settles the held orders it pays. Fails closed: when the nodes cannot be read, nothing is credited. */
22
+ export declare function scanPayments(store: ShopStore, opts: ScanOptions): Promise<ScanResult>;
23
+ /** For tests: forget the kept send blocks. */
24
+ export declare function forgetBlocks(): void;
@@ -0,0 +1,80 @@
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
+ import { candidates, checkSend } from "./match.js";
7
+ import { rpc } from "./nodes.js";
8
+ import { smallestAmount } from "./orders.js";
9
+ export const SCAN_GAP_MS = 4000;
10
+ /** The most entries one RPC read asks for. */
11
+ const COUNT = "50";
12
+ /** A confirmed send never changes: its answer is kept for an hour (in this copy of the server). */
13
+ const blockCache = new Map();
14
+ async function blockInfo(nodes, hash, o, now) {
15
+ const hit = blockCache.get(hash);
16
+ if (hit && hit.until > now)
17
+ return hit.info;
18
+ const { answer } = await rpc(nodes, { action: "block_info", hash, json_block: "true" }, o);
19
+ if (answer && (answer.confirmed === "true" || answer.confirmed === true)) {
20
+ if (blockCache.size > 500)
21
+ blockCache.clear();
22
+ blockCache.set(hash, { info: answer, until: now + 3_600_000 });
23
+ }
24
+ return answer;
25
+ }
26
+ /** Scans the creator's address once and settles the held orders it pays. Fails closed: when the nodes cannot be read, nothing is credited. */
27
+ export async function scanPayments(store, opts) {
28
+ const now = opts.at ?? Date.now();
29
+ const o = opts.rpc ?? {};
30
+ await store.tidy(now);
31
+ const address = opts.address;
32
+ if (!address)
33
+ return { scanned: false, settled: [] };
34
+ const held = await store.held();
35
+ if (!held.length)
36
+ return { scanned: false, settled: [] };
37
+ if (!(await store.claimScan(now, SCAN_GAP_MS)))
38
+ return { scanned: false, settled: [] };
39
+ const settled = [];
40
+ try {
41
+ // Only amounts a held order asks for: small sends ("dust") cannot push a payment out of the list.
42
+ const threshold = smallestAmount(held);
43
+ const receivable = await rpc(opts.nodes, { action: "receivable", account: address, count: COUNT, threshold, source: "true" }, o);
44
+ // A new address has no history yet: the node answers "Account not found" (null here).
45
+ const history = await rpc(opts.nodes, { action: "account_history", account: address, count: COUNT, raw: "true" }, o);
46
+ const byAmount = new Map(held.map((h) => [h.amount, h]));
47
+ const found = candidates(receivable.answer, history.answer, new Set(byAmount.keys()));
48
+ if (!found.size)
49
+ return { scanned: true, settled };
50
+ // A payment that already paid (or was recorded for) an order never counts again.
51
+ const used = await store.usedHashes([...found.keys()]);
52
+ for (const [hash, amount] of found) {
53
+ if (used.has(hash))
54
+ continue;
55
+ const order = byAmount.get(amount);
56
+ if (!order)
57
+ continue;
58
+ const info = await blockInfo(opts.nodes, hash, o, now);
59
+ if (!info)
60
+ continue;
61
+ const verdict = checkSend(info, address, order.amount, order.createdAt);
62
+ if (verdict.kind !== "paid")
63
+ continue;
64
+ const result = await store.settle(order, { hash, payer: verdict.payer, seenMs: verdict.seenMs }, now);
65
+ if (result === "paid" || result === "paid_late") {
66
+ settled.push({ id: order.id, status: result });
67
+ byAmount.delete(amount);
68
+ }
69
+ }
70
+ return { scanned: true, settled };
71
+ }
72
+ catch (e) {
73
+ // Fail closed: nothing is credited when the nodes cannot be read. The next scan tries again.
74
+ return { scanned: false, settled, error: e instanceof Error ? e.message : String(e) };
75
+ }
76
+ }
77
+ /** For tests: forget the kept send blocks. */
78
+ export function forgetBlocks() {
79
+ blockCache.clear();
80
+ }
@@ -0,0 +1,10 @@
1
+ /** Turnstile (Cloudflare's free check that a visitor is a person) loads from here. */
2
+ export declare const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
3
+ /** The sources the shop adds, by directive. */
4
+ export declare const SHOP_CSP: Readonly<Record<string, readonly string[]>>;
5
+ /**
6
+ * 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
7
+ * 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.
8
+ * A directive that has 'none' and gets a source loses the 'none' (the two cannot stand together).
9
+ */
10
+ export declare function mergeCsp(policy: string | Readonly<Record<string, readonly string[]>>, ...more: Readonly<Record<string, readonly string[]>>[]): string;
@@ -0,0 +1,56 @@
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
+ /** Turnstile (Cloudflare's free check that a visitor is a person) loads from here. */
10
+ export const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
11
+ /** The sources the shop adds, by directive. */
12
+ export const SHOP_CSP = {
13
+ "script-src": [TURNSTILE_ORIGIN],
14
+ "frame-src": [TURNSTILE_ORIGIN],
15
+ "connect-src": ["'self'"],
16
+ };
17
+ /**
18
+ * 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
19
+ * 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.
20
+ * A directive that has 'none' and gets a source loses the 'none' (the two cannot stand together).
21
+ */
22
+ export function mergeCsp(policy, ...more) {
23
+ const map = new Map();
24
+ const add = (directive, sources) => {
25
+ const name = directive.trim().toLowerCase();
26
+ if (!/^[a-z-]+$/.test(name))
27
+ throw new Error(`not a CSP directive: ${directive}`);
28
+ const list = map.get(name) ?? [];
29
+ for (const s of sources) {
30
+ if (!s || /[;,\s]/.test(s))
31
+ throw new Error(`not a CSP source: ${s}`);
32
+ if (!list.includes(s))
33
+ 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)
41
+ add(name, sources);
42
+ }
43
+ }
44
+ else
45
+ for (const [k, v] of Object.entries(policy))
46
+ add(k, v);
47
+ for (const m of more)
48
+ for (const [k, v] of Object.entries(m))
49
+ add(k, v);
50
+ return [...map]
51
+ .map(([name, sources]) => {
52
+ const list = sources.length > 1 ? sources.filter((s) => s !== "'none'") : sources;
53
+ return list.length ? `${name} ${list.join(" ")}` : name;
54
+ })
55
+ .join("; ");
56
+ }
@@ -0,0 +1,101 @@
1
+ export type OrderStatus = "open" | "paid" | "expired" | "paid_late";
2
+ /** One order: a player asked to buy a look. Amounts are raw, as text; times are ms since 1970. */
3
+ export type ShopOrder = {
4
+ /** 24 hex characters. */
5
+ id: string;
6
+ /** 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. */
7
+ player: string;
8
+ lookId: string;
9
+ /** The price in US dollars when the order was made: "0.25". */
10
+ usd: string;
11
+ /** Dollars per XNO that turned the price into XNO. */
12
+ rate: number;
13
+ /** The exact amount to pay: a clean amount (at most 4 decimals of XNO). */
14
+ amount: string;
15
+ /**
16
+ * 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
17
+ * (the store's unique rule), so one payment can only match one order.
18
+ */
19
+ amountLock: string | null;
20
+ status: OrderStatus;
21
+ createdAt: number;
22
+ /** The order takes a payment until then. */
23
+ expiresAt: number;
24
+ /** The amount stays reserved until then (a late payment is still seen, and never counts for a newer order). */
25
+ holdUntil: number;
26
+ /** The paying send block: one payment pays one order, once (the store's unique rule). */
27
+ hash: string | null;
28
+ /** The account that sent it. */
29
+ payer: string | null;
30
+ /** When the node first saw that send. */
31
+ paidAt: number | null;
32
+ /** A keyed hash of the buyer's IP address, for limits (never the address), or null. */
33
+ visitor: string | null;
34
+ };
35
+ /** What `settle` did. "used": that payment already paid another order. "taken": this order was settled meanwhile (or its amount is no longer held). */
36
+ export type Settled = "paid" | "paid_late" | "used" | "taken";
37
+ /** A payment the scan found: the send's hash, its account and when the node first saw it. */
38
+ export type Payment = {
39
+ hash: string;
40
+ payer: string;
41
+ seenMs: number;
42
+ };
43
+ /** The price of XNO the shop keeps (the median of the feeds) and when it read it. */
44
+ export type KeptRate = {
45
+ usd: number;
46
+ sources: string[];
47
+ 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 = {
55
+ deleted: number;
56
+ kept: number;
57
+ waitUntil: number | null;
58
+ };
59
+ /** The stand-in for a player who used "Delete my data", in the orders that stay as the creator's sales record. */
60
+ export declare const FORGOTTEN = "deleted";
61
+ /**
62
+ * 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
63
+ * transaction, a batch, or a statement that holds its own check.
64
+ */
65
+ export interface ShopStore {
66
+ /** Marks the open orders past `expiresAt` "expired", and frees the amounts whose `holdUntil` is past (the amount lock goes, the order stays). */
67
+ tidy(now: number): Promise<void>;
68
+ /** Which of `amounts` an order holds now. */
69
+ heldAmounts(amounts: readonly string[]): Promise<Set<string>>;
70
+ /**
71
+ * Writes a new order that holds its amount (`amountLock` = `amount`). False, and nothing written, when another order holds that amount now: the
72
+ * unique rule, checked in the same step as the write. Any other failure throws.
73
+ */
74
+ insert(order: ShopOrder): Promise<boolean>;
75
+ order(id: string): Promise<ShopOrder | null>;
76
+ /** The newest open order of this player for this look that still runs at `until`, or null. */
77
+ openOrder(player: string, lookId: string, until: number): Promise<ShopOrder | null>;
78
+ /** How many open orders this player has that still run at `now`. */
79
+ countOpen(player: string, now: number): Promise<number>;
80
+ /** Whether a paid order gave this player this look. */
81
+ owns(player: string, lookId: string): Promise<boolean>;
82
+ /** The ids of every look that paid orders gave this player (free looks are everyone's and are not stored). */
83
+ owned(player: string): Promise<string[]>;
84
+ /** The orders whose amount is held: the only ones a payment can be matched to. */
85
+ held(): Promise<ShopOrder[]>;
86
+ /** Which of `hashes` already paid (or were recorded for) an order. */
87
+ usedHashes(hashes: readonly string[]): Promise<Set<string>>;
88
+ /**
89
+ * 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
90
+ * 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);
91
+ * 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,
92
+ * or its hold ended), else "used" when the hash paid another order. Either way nothing changes.
93
+ */
94
+ settle(order: ShopOrder, pay: Payment, now: number): Promise<Settled>;
95
+ /** Claims the next payment scan: true for exactly one caller in each `gapMs`. */
96
+ claimScan(now: number, gapMs: number): Promise<boolean>;
97
+ readRate(): Promise<KeptRate | null>;
98
+ saveRate(rate: KeptRate): Promise<void>;
99
+ /** "Delete my data" for the shop (see ForgetResult). */
100
+ forget(player: string, now: number): Promise<ForgetResult>;
101
+ }
@@ -0,0 +1,4 @@
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
+ /** The stand-in for a player who used "Delete my data", in the orders that stay as the creator's sales record. */
4
+ export const FORGOTTEN = "deleted";
@@ -0,0 +1,43 @@
1
+ import type { D1Like } from "../admin/adapters/d1.js";
2
+ import { type ShopTables } from "./adapters/d1.js";
3
+ import { SHOP_LIMITS, type ShopHandler, type ShopHandlerOptions } from "./handler.js";
4
+ import { type Fetch } from "./nodes.js";
5
+ import type { ShopStore } from "./store.js";
6
+ /** The settings the shop reads. Every one is optional except the database. */
7
+ export type ShopEnv = {
8
+ DB: D1Like;
9
+ RECEIVE_ADDRESS?: string;
10
+ TEST_MODE?: string;
11
+ NODE_API_KEY?: string;
12
+ NANO_NODES?: string;
13
+ TURNSTILE_SITE_KEY?: string;
14
+ TURNSTILE_SECRET?: string;
15
+ };
16
+ export type ShopOptions = Pick<ShopHandlerOptions, "catalogue" | "player" | "base" | "now" | "onError" | "rate" | "rpc"> & {
17
+ /** The D1 table names (default: the starter's). */
18
+ tables?: ShopTables;
19
+ /** The `fetch` for Turnstile's check (a test gives its own). */
20
+ fetch?: Fetch;
21
+ };
22
+ /** Whether Turnstile is on: the site key AND the secret are both set. */
23
+ export declare const turnstileOn: (env: Pick<ShopEnv, "TURNSTILE_SITE_KEY" | "TURNSTILE_SECRET">) => boolean;
24
+ /** True when the person check passes, or when Turnstile is off (the starter's turnstile.ts). The secret is sent to Cloudflare only. */
25
+ export declare function turnstileCheck(env: Pick<ShopEnv, "TURNSTILE_SITE_KEY" | "TURNSTILE_SECRET">, token: unknown, ip: string | null, fetchFn?: Fetch): Promise<boolean>;
26
+ export type Shop = ShopHandler & {
27
+ store: ShopStore;
28
+ /** 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. */
29
+ config(): {
30
+ testMode: boolean;
31
+ turnstileSiteKey: string | null;
32
+ };
33
+ /** For the admin's Status card: the address, test mode, Turnstile, the nodes' names and plain sentences about what is still off. */
34
+ status(): {
35
+ address: string | null;
36
+ testMode: boolean;
37
+ turnstile: boolean;
38
+ nodes: string[];
39
+ warnings: string[];
40
+ };
41
+ };
42
+ export declare function createShop(env: ShopEnv, options: ShopOptions): Shop;
43
+ export { SHOP_LIMITS };
@@ -0,0 +1,76 @@
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
+ import { createD1Shop, d1Limit, visitorKey } from "./adapters/d1.js";
16
+ import { createShopHandler, SHOP_LIMITS } from "./handler.js";
17
+ import { nodesFor, settingValue } from "./nodes.js";
18
+ const VERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
19
+ /** "on", "true", "1" or "yes" (any case). */
20
+ const isOn = (v) => /^(on|true|1|yes)$/i.test((v ?? "").trim());
21
+ /** Whether Turnstile is on: the site key AND the secret are both set. */
22
+ export const turnstileOn = (env) => Boolean(settingValue(env.TURNSTILE_SECRET) && settingValue(env.TURNSTILE_SITE_KEY));
23
+ /** True when the person check passes, or when Turnstile is off (the starter's turnstile.ts). The secret is sent to Cloudflare only. */
24
+ export async function turnstileCheck(env, token, ip, fetchFn = (i, init) => fetch(i, init)) {
25
+ if (!turnstileOn(env))
26
+ return true;
27
+ if (typeof token !== "string" || !token || token.length > 2048)
28
+ return false;
29
+ const form = new FormData();
30
+ form.append("secret", settingValue(env.TURNSTILE_SECRET));
31
+ form.append("response", token);
32
+ if (ip)
33
+ form.append("remoteip", ip);
34
+ try {
35
+ const res = await fetchFn(VERIFY, { method: "POST", body: form, signal: AbortSignal.timeout(5000) });
36
+ const out = (await res.json());
37
+ return out.success === true;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ export function createShop(env, options) {
44
+ const store = createD1Shop(env.DB, { tables: options.tables });
45
+ const nodes = nodesFor(env);
46
+ const turnstile = turnstileOn(env);
47
+ const handler = createShopHandler({
48
+ ...options,
49
+ store,
50
+ payee: env.RECEIVE_ADDRESS ?? null,
51
+ testMode: isOn(env.TEST_MODE),
52
+ nodes,
53
+ visitor: (req) => visitorKey(env.DB, req, { meta: options.tables?.meta }),
54
+ // 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`).
55
+ limit: (key, limit, windowMs, at) => d1Limit(env.DB, key, limit, windowMs, at),
56
+ personCheck: turnstile ? (token, req) => turnstileCheck(env, token, req.headers.get("CF-Connecting-IP"), options.fetch) : undefined,
57
+ });
58
+ return {
59
+ ...handler,
60
+ store,
61
+ config: () => ({ testMode: handler.testMode, turnstileSiteKey: turnstile ? settingValue(env.TURNSTILE_SITE_KEY) : null }),
62
+ status() {
63
+ const warnings = [];
64
+ if (handler.testMode)
65
+ 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.");
66
+ if (!turnstile)
67
+ warnings.push(settingValue(env.TURNSTILE_SECRET) || settingValue(env.TURNSTILE_SITE_KEY)
68
+ ? "Turnstile is OFF: set both TURNSTILE_SITE_KEY and the secret TURNSTILE_SECRET."
69
+ : "Turnstile is OFF: sign-in and orders are not checked for bots. Add a free Turnstile widget (see README).");
70
+ if (!settingValue(env.NODE_API_KEY))
71
+ warnings.push("No NODE_API_KEY: payments are checked on public Nano nodes only, which promise no uptime.");
72
+ return { address: handler.payee, testMode: handler.testMode, turnstile, nodes: nodes.map((n) => n.name), warnings };
73
+ },
74
+ };
75
+ }
76
+ export { SHOP_LIMITS };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nano133/nanoterra",
3
- "version": "0.8.0",
4
- "description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton), the looks ladder, the admin, the friends list and an options menu.",
3
+ "version": "0.9.0",
4
+ "description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton), the looks ladder, the admin, the friends list, an options menu, emotes, the player's sign-in (a passkey or a wallet app) and a shop that sells looks for Nano with no key on the server.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -33,7 +33,22 @@
33
33
  "./emotes/player": { "types": "./dist/emotes/player.d.ts", "import": "./dist/emotes/player.js" },
34
34
  "./emotes/wheel": { "types": "./dist/emotes/panel/index.d.ts", "import": "./dist/emotes/panel/index.js" },
35
35
  "./emotes/style.css": "./src/emotes/panel/style.css",
36
- "./emotes/icons.webp": "./src/emotes/icons.webp"
36
+ "./emotes/icons.webp": "./src/emotes/icons.webp",
37
+ "./player-signin": { "types": "./dist/player-signin/index.d.ts", "import": "./dist/player-signin/index.js" },
38
+ "./player-signin/conformance": { "types": "./dist/player-signin/conformance.d.ts", "import": "./dist/player-signin/conformance.js" },
39
+ "./player-signin/d1": { "types": "./dist/player-signin/adapters/d1.d.ts", "import": "./dist/player-signin/adapters/d1.js" },
40
+ "./player-signin/d1.sql": "./src/player-signin/adapters/d1.sql",
41
+ "./player-signin/firestore": { "types": "./dist/player-signin/adapters/firestore.d.ts", "import": "./dist/player-signin/adapters/firestore.js" },
42
+ "./player-signin/panel": { "types": "./dist/player-signin/panel/index.d.ts", "import": "./dist/player-signin/panel/index.js" },
43
+ "./player-signin/style.css": "./src/player-signin/panel/style.css",
44
+ "./shop": { "types": "./dist/shop/index.d.ts", "import": "./dist/shop/index.js" },
45
+ "./shop/conformance": { "types": "./dist/shop/conformance.d.ts", "import": "./dist/shop/conformance.js" },
46
+ "./shop/d1": { "types": "./dist/shop/adapters/d1.d.ts", "import": "./dist/shop/adapters/d1.js" },
47
+ "./shop/d1.sql": "./src/shop/adapters/d1.sql",
48
+ "./shop/firestore": { "types": "./dist/shop/adapters/firestore.d.ts", "import": "./dist/shop/adapters/firestore.js" },
49
+ "./shop/worker": { "types": "./dist/shop/worker.d.ts", "import": "./dist/shop/worker.js" },
50
+ "./shop/panel": { "types": "./dist/shop/panel/index.d.ts", "import": "./dist/shop/panel/index.js" },
51
+ "./shop/style.css": "./src/shop/panel/style.css"
37
52
  },
38
53
  "sideEffects": false,
39
54
  "files": ["dist", "src", "README.md", "CHANGELOG.md", "LICENSE", "ai"],
@@ -43,8 +58,8 @@
43
58
  "test": "node --import tsx --test test/*.test.ts",
44
59
  "prepublishOnly": "npm run build"
45
60
  },
46
- "peerDependencies": { "three": ">=0.174.0" },
47
- "peerDependenciesMeta": { "three": { "optional": true } },
61
+ "peerDependencies": { "three": ">=0.174.0", "@nano133/signin": "^0.5.0" },
62
+ "peerDependenciesMeta": { "three": { "optional": true }, "@nano133/signin": { "optional": true } },
48
63
  "devDependencies": {
49
64
  "@types/node": "^26.6.3",
50
65
  "@types/three": "^0.186.0",
@@ -61,5 +76,5 @@
61
76
  },
62
77
  "homepage": "https://nano133.com/build/nanoterra",
63
78
  "license": "MIT",
64
- "keywords": ["nano", "gltf", "glb", "model check", "xno", "nanoterra", "sign in", "ticket", "character", "avatar", "three", "three.js", "toon", "looks", "cosmetics", "friends", "friends list", "options", "settings", "options menu", "emotes", "emote wheel"]
79
+ "keywords": ["nano", "gltf", "glb", "model check", "xno", "nanoterra", "sign in", "ticket", "character", "avatar", "three", "three.js", "toon", "looks", "cosmetics", "friends", "friends list", "options", "settings", "options menu", "emotes", "emote wheel", "passkey", "webauthn", "berry wallet", "nautilus", "nanchat", "shop", "pay", "qr code"]
65
80
  }
@@ -16,6 +16,12 @@ export function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Attrs =
16
16
  return el;
17
17
  }
18
18
 
19
+ /** Takes the keyboard focus out of `within` (to the page), so a game's own keys reach the game again. */
20
+ export function releaseFocus(within: Element): void {
21
+ const now = document.activeElement;
22
+ if (now instanceof HTMLElement && within.contains(now)) now.blur();
23
+ }
24
+
19
25
  /** "nano_1abc…wxyz" */
20
26
  export const shortAddress = (a: string): string => `${a.slice(0, 10)}…${a.slice(-6)}`;
21
27
 
@@ -8,6 +8,7 @@
8
8
  import type { Look } from "../../looks/look.js";
9
9
  import type { AdminSummary } from "../store.js";
10
10
  import type { GameState } from "../switches.js";
11
+ import { PASSKEY_PREVIEW_NOTE, previewHost } from "../../player-signin/host.js";
11
12
  import { ApiError, createApi, type Api } from "./api.js";
12
13
  import { copyKey, h, plural, shortAddress } from "./dom.js";
13
14
  import { gameCard } from "./gameCard.js";
@@ -39,6 +40,9 @@ export type MountOptions = {
39
40
  repoHelp?: string;
40
41
  /** What the Sign-ins card says about what the game keeps and for how long ("30 days"). Default: only the time, the rough country and a short browser name. */
41
42
  loginsNote?: string;
43
+ /** The game offers its players a passkey (`@nano133/nanoterra/player-signin`): on a preview address (`*.workers.dev`) the banner says that passkeys turn
44
+ * on with the game's own domain. */
45
+ playerPasskeys?: boolean;
42
46
  /** The `fetch` to use (default: the page's). */
43
47
  fetch?: typeof fetch;
44
48
  };
@@ -218,8 +222,9 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
218
222
  h("span", { class: `chip ${s.testMode ? "chip-warn" : "chip-ok"}`, title: s.testMode ? "Payments are not real" : "Payments are real" }, h("span", { class: "chip-dot", "aria-hidden": "true" }), s.testMode ? "Test mode" : "Live"),
219
223
  s.turnstile === undefined ? "" : h("span", { class: `chip ${s.turnstile ? "chip-ok" : "chip-warn"}` }, h("span", { class: "chip-dot", "aria-hidden": "true" }), s.turnstile ? "Bot check on" : "Bot check off"),
220
224
  );
221
- banner.hidden = !s.warnings.length;
222
- banner.replaceChildren(h("div", { class: "banner-icon", "aria-hidden": "true" }, icon("alert", 20)), h("div", {}, h("h2", {}, "Check these"), h("ul", {}, ...s.warnings.map((w) => h("li", {}, w)))));
225
+ const notes = options.playerPasskeys && previewHost(location.hostname) ? [...s.warnings, PASSKEY_PREVIEW_NOTE] : s.warnings;
226
+ banner.hidden = !notes.length;
227
+ banner.replaceChildren(h("div", { class: "banner-icon", "aria-hidden": "true" }, icon("alert", 20)), h("div", {}, h("h2", {}, "Check these"), h("ul", {}, ...notes.map((w) => h("li", {}, w)))));
223
228
 
224
229
  const ownedLooks = s.players.reduce((n, p) => n + p.looks.length, 0);
225
230
  const late = s.totals.paidLate.count;
@@ -10,7 +10,7 @@
10
10
  // The list is Nanoterra's (each id names one of EMOTES: its name and picture come from the kit), the game's own ({ id, label, icon }), or both. The
11
11
  // wheel only calls `onPlay(id)`: the game plays it (Nanoterra's on the kit's character: createEmotes(...).play, from "@nano133/nanoterra/emotes/player").
12
12
 
13
- import { h } from "../../admin/panel/dom.js";
13
+ import { h, releaseFocus } from "../../admin/panel/dom.js";
14
14
  import { icon } from "../../admin/panel/icons.js";
15
15
  import { EMOTE_ICONS_URL } from "../icons.js";
16
16
  import { EMOTES, emoteOf, isEmote, type EmoteId } from "../list.js";
@@ -47,6 +47,11 @@ export type EmotesMountOptions = {
47
47
  theme?: "light" | "dark" | "device";
48
48
  /** Where the wheel is kept (default: the page's localStorage). null keeps it only for the life of the page. */
49
49
  storage?: Pick<Storage, "getItem" | "setItem"> | null;
50
+ /**
51
+ * Where the keyboard focus goes when the wheel closes. "game" (the default): out of the key and the wheel, to the page, so Space and the game's
52
+ * other keys reach the game again (a focused key would take Space and open the wheel again). "key": back to the key, for a page with no keys of its own.
53
+ */
54
+ focusAfterClose?: "game" | "key";
50
55
  };
51
56
 
52
57
  export type MountedEmotes = {
@@ -288,7 +293,9 @@ export function mountEmotes(root: HTMLElement, options: EmotesMountOptions): Mou
288
293
  layer.hidden = true;
289
294
  layer.dataset.open = "false";
290
295
  key.setAttribute("aria-expanded", "false");
291
- if (focusKey && !key.hidden) key.focus();
296
+ if (options.focusAfterClose === "key") {
297
+ if (focusKey && !key.hidden) key.focus();
298
+ } else releaseFocus(wrap);
292
299
  }
293
300
 
294
301
  editKey.addEventListener("click", () => {
@@ -12,7 +12,7 @@
12
12
  // The panel is phone first: a sheet from the bottom on a phone held upright, from the side on a wide or sideways screen; every key is 44 px.
13
13
 
14
14
  import { ApiError, createApi, type Api } from "../../admin/panel/api.js";
15
- import { h } from "../../admin/panel/dom.js";
15
+ import { h, releaseFocus } from "../../admin/panel/dom.js";
16
16
  import { icon } from "../../admin/panel/icons.js";
17
17
  import type { FriendRow, FriendView } from "../types.js";
18
18
  import { readWindowMessage, toWindow, whereText, windowUrl, type ToWindow } from "./window.js";
@@ -39,6 +39,11 @@ export type FriendsMountOptions = {
39
39
  fetch?: typeof fetch;
40
40
  /** Asks the player a yes or no question (default: a question in the panel, with a key for each answer; never a browser dialog). */
41
41
  confirm?: (question: string) => boolean | Promise<boolean>;
42
+ /**
43
+ * Where the keyboard focus goes when the panel closes. "game" (the default): out of the key and the panel, to the page, so Space and the game's
44
+ * other keys reach the game again (a focused key would take Space and open the panel again). "key": back to the key, for a page with no keys of its own.
45
+ */
46
+ focusAfterClose?: "game" | "key";
42
47
  };
43
48
 
44
49
  export type MountedFriends = {
@@ -108,7 +113,8 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
108
113
  panel.dataset.open = "false";
109
114
  key.setAttribute("aria-expanded", "false");
110
115
  onClose();
111
- key.focus();
116
+ if (options.focusAfterClose === "key") key.focus();
117
+ else releaseFocus(wrap);
112
118
  };
113
119
  key.addEventListener("click", () => (isOpen ? close() : open()));
114
120
  closeKey.addEventListener("click", close);