@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,116 @@
1
+ /** The wallet apps a player can sign in with, in the order the sheet shows them. */
2
+ export declare const WAYS: readonly ["berry", "nanchat", "nautilus"];
3
+ export type Way = (typeof WAYS)[number];
4
+ /** 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. */
5
+ export type Via = "wallet" | "passkey" | "nanoterra";
6
+ export declare const VIAS: readonly Via[];
7
+ /** A wallet app's sign-in request, as a store keeps it. The same shape as @nano133/signin's `WalletRequest`. */
8
+ export type SigninRequest = {
9
+ /** The browser's id for its checks. */
10
+ id: string;
11
+ /** The SHA-256 (hex) of the starter cookie of the browser that asked. */
12
+ starter: string;
13
+ status: "waiting" | "signedin";
14
+ /** When it stops taking a signature (ms). */
15
+ expiresAt: number;
16
+ /** Nautilus: the label the app shows and signs. */
17
+ label?: string;
18
+ /** Berry Wallet: the nonce and when it was issued (ms). */
19
+ nonce?: string;
20
+ issuedAt?: number;
21
+ /** The account that signed, once it is signed. */
22
+ account?: string;
23
+ };
24
+ /**
25
+ * 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
26
+ * 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:
27
+ * waiting → signed, once; false otherwise.
28
+ */
29
+ export interface RequestStoreLike {
30
+ create(key: string, r: SigninRequest): Promise<void>;
31
+ get(key: string): Promise<SigninRequest | null>;
32
+ find(id: string): Promise<{
33
+ key: string;
34
+ request: SigninRequest;
35
+ } | null>;
36
+ finish(key: string, account: string): Promise<boolean>;
37
+ }
38
+ /** A session as a store keeps it. `idHash` is the SHA-256 (hex) of the cookie's token: the token itself is never stored. */
39
+ export type SessionRecord = {
40
+ idHash: string;
41
+ address: string;
42
+ createdAt: number;
43
+ expiresAt: number;
44
+ via: Via;
45
+ };
46
+ /** 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). */
47
+ export type PlayerSession = {
48
+ address: string;
49
+ createdAt: number;
50
+ via: Via | "unknown";
51
+ };
52
+ /**
53
+ * The game's database for the player sign-in. An adapter reads and writes ONLY its three tables or collections (requests, sessions, once-only
54
+ * 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).
55
+ * Run `playerSigninStoreConformance` on a store you write yourself.
56
+ */
57
+ export interface PlayerSigninStore {
58
+ /** The requests of one wallet app. */
59
+ requests(way: Way): RequestStoreLike;
60
+ /**
61
+ * 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,
62
+ * false (and no session written) when it was delivered before, is not signed, or is not known.
63
+ */
64
+ deliver(way: Way, key: string, session: SessionRecord): Promise<boolean>;
65
+ createSession(session: SessionRecord): Promise<void>;
66
+ /** The session of a token's hash while it lasts (`expiresAt` > now), else null. */
67
+ session(idHash: string, now: number): Promise<SessionRecord | null>;
68
+ endSession(idHash: string): Promise<void>;
69
+ /** 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. */
70
+ useOnce(key: string, ttlMs: number, address: string): Promise<boolean>;
71
+ /** A player's "Delete my data": every session, every signed request and every once-only row of `address`. Returns how many rows went. */
72
+ forget(address: string): Promise<number>;
73
+ /** Deletes what nobody needs any more: requests 2 hours after they ended, sessions that ended, once-only rows past their time. */
74
+ clean(now: number): Promise<void>;
75
+ }
76
+ /** 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. */
77
+ export declare const PLAYER_SIGNIN_LIMITS: {
78
+ /** A wallet sign-in start or a passkey sign-in, per visitor (hook). */
79
+ readonly signin: {
80
+ readonly limit: 20;
81
+ readonly windowMs: number;
82
+ };
83
+ /** The browser's checks (every 2 s), per visitor (memory). */
84
+ readonly poll: {
85
+ readonly limit: 90;
86
+ readonly windowMs: 60000;
87
+ };
88
+ /** Berry Wallet's GET and POST and Nautilus's answer, per IP address (memory): the wallet's own phone sends them. */
89
+ readonly wallet: {
90
+ readonly limit: 30;
91
+ readonly windowMs: 60000;
92
+ };
93
+ /** 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). */
94
+ readonly nanchatRoute: {
95
+ readonly limit: 120;
96
+ readonly windowMs: 60000;
97
+ };
98
+ readonly nanchatAll: {
99
+ readonly limit: 600;
100
+ readonly windowMs: number;
101
+ };
102
+ readonly nanchatNonce: {
103
+ readonly limit: 5;
104
+ readonly windowMs: number;
105
+ };
106
+ /** The biggest body the routes read. */
107
+ readonly bodyMax: 8192;
108
+ };
109
+ /** How long a player's session lasts (the game starter's 30 days). */
110
+ export declare const SESSION_DAYS = 30;
111
+ /** How long the starter cookie lives: a wallet's request must be answered within it. */
112
+ export declare const STARTER_MAX_AGE_S: number;
113
+ /** A token, a starter value: 32 random bytes as 64 hex characters. */
114
+ export declare const TOKEN_PATTERN: RegExp;
115
+ /** A store's word for `via`, as the game reads it. */
116
+ export declare const viaOf: (v: unknown) => Via | "unknown";
@@ -0,0 +1,33 @@
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
+ /** The wallet apps a player can sign in with, in the order the sheet shows them. */
9
+ export const WAYS = ["berry", "nanchat", "nautilus"];
10
+ export const VIAS = ["wallet", "passkey", "nanoterra"];
11
+ /** 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. */
12
+ export const PLAYER_SIGNIN_LIMITS = {
13
+ /** A wallet sign-in start or a passkey sign-in, per visitor (hook). */
14
+ signin: { limit: 20, windowMs: 10 * 60_000 },
15
+ /** The browser's checks (every 2 s), per visitor (memory). */
16
+ poll: { limit: 90, windowMs: 60_000 },
17
+ /** Berry Wallet's GET and POST and Nautilus's answer, per IP address (memory): the wallet's own phone sends them. */
18
+ wallet: { limit: 30, windowMs: 60_000 },
19
+ /** 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). */
20
+ nanchatRoute: { limit: 120, windowMs: 60_000 },
21
+ nanchatAll: { limit: 600, windowMs: 10 * 60_000 },
22
+ nanchatNonce: { limit: 5, windowMs: 10 * 60_000 },
23
+ /** The biggest body the routes read. */
24
+ bodyMax: 8192,
25
+ };
26
+ /** How long a player's session lasts (the game starter's 30 days). */
27
+ export const SESSION_DAYS = 30;
28
+ /** How long the starter cookie lives: a wallet's request must be answered within it. */
29
+ export const STARTER_MAX_AGE_S = 30 * 60;
30
+ /** A token, a starter value: 32 random bytes as 64 hex characters. */
31
+ export const TOKEN_PATTERN = /^[0-9a-f]{64}$/;
32
+ /** A store's word for `via`, as the game reads it. */
33
+ export const viaOf = (v) => (VIAS.includes(v) ? v : "unknown");
@@ -0,0 +1,25 @@
1
+ import type { D1Like } from "../../admin/adapters/d1.js";
2
+ import { type ShopStore } from "../store.js";
3
+ export type { D1Like, D1StatementLike } from "../../admin/adapters/d1.js";
4
+ /** The table names. The defaults are the game starter's. (`hits` is for `d1Limit` only.) */
5
+ export type ShopTables = {
6
+ orders?: string;
7
+ owned?: string;
8
+ meta?: string;
9
+ hits?: string;
10
+ };
11
+ /**
12
+ * 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).
13
+ * The game starter's `d1Limit`. Old windows stay until the game deletes them (the starter's cron deletes those older than a day).
14
+ */
15
+ export declare function d1Limit(db: D1Like, key: string, limit: number, windowMs: number, now?: number, tables?: Pick<ShopTables, "hits">): Promise<boolean>;
16
+ /** 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. */
17
+ export declare function visitorIp(req: Request): string;
18
+ /**
19
+ * 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
20
+ * starter's `visitorKey`: the same salt row, so the keys stay the same after a switch.
21
+ */
22
+ export declare function visitorKey(db: D1Like, req: Request, tables?: Pick<ShopTables, "meta">): Promise<string>;
23
+ export declare function createD1Shop(db: D1Like, options?: {
24
+ tables?: ShopTables;
25
+ }): ShopStore;
@@ -0,0 +1,200 @@
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
+ import { parseRate } from "../../admin/summary.js";
14
+ import { paidStatus } from "../orders.js";
15
+ import { FORGOTTEN } from "../store.js";
16
+ const STATUSES = ["open", "paid", "expired", "paid_late"];
17
+ const orderOf = (r) => ({
18
+ id: r.id,
19
+ player: r.player,
20
+ lookId: r.look_id,
21
+ usd: r.usd,
22
+ rate: Number(r.rate),
23
+ amount: r.amount,
24
+ amountLock: r.amount_lock ?? null,
25
+ status: STATUSES.includes(r.status) ? r.status : "expired",
26
+ createdAt: Number(r.created_at),
27
+ expiresAt: Number(r.expires_at),
28
+ holdUntil: Number(r.hold_until),
29
+ hash: r.hash ?? null,
30
+ payer: r.payer ?? null,
31
+ paidAt: r.paid_at === null || r.paid_at === undefined ? null : Number(r.paid_at),
32
+ visitor: r.visitor ?? null,
33
+ });
34
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
35
+ const changes = (res) => Number(res?.meta?.changes ?? 0);
36
+ const isUnique = (e) => /UNIQUE/i.test(String(e?.message ?? e));
37
+ /** D1 takes at most 100 bound values in one statement. */
38
+ const CHUNK = 90;
39
+ const tableName = (v, fallback) => {
40
+ const name = v ?? fallback;
41
+ if (!NAME.test(name))
42
+ throw new Error(`the table name "${name}" must be plain (letters, digits and _)`);
43
+ return name;
44
+ };
45
+ async function sha256Hex(text) {
46
+ const bytes = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
47
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
48
+ }
49
+ /**
50
+ * 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).
51
+ * The game starter's `d1Limit`. Old windows stay until the game deletes them (the starter's cron deletes those older than a day).
52
+ */
53
+ export async function d1Limit(db, key, limit, windowMs, now = Date.now(), tables = {}) {
54
+ const hits = tableName(tables.hits, "hits");
55
+ const win = Math.floor(now / windowMs);
56
+ 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();
57
+ return Number(row?.count ?? 1) <= limit;
58
+ }
59
+ /** 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. */
60
+ export function visitorIp(req) {
61
+ const ip = req.headers.get("CF-Connecting-IP") ?? "local";
62
+ return ip.includes(":") ? ip.split(":").slice(0, 4).join(":") + "::/64" : ip;
63
+ }
64
+ /**
65
+ * 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
66
+ * starter's `visitorKey`: the same salt row, so the keys stay the same after a switch.
67
+ */
68
+ export async function visitorKey(db, req, tables = {}) {
69
+ const meta = tableName(tables.meta, "meta");
70
+ let row = await db.prepare(`SELECT value FROM ${meta} WHERE key = 'salt'`).first();
71
+ if (!row) {
72
+ const fresh = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), (x) => x.toString(16).padStart(2, "0")).join("");
73
+ await db.prepare(`INSERT OR IGNORE INTO ${meta} (key, value, at) VALUES ('salt', ?, ?)`).bind(fresh, Date.now()).run();
74
+ row = await db.prepare(`SELECT value FROM ${meta} WHERE key = 'salt'`).first();
75
+ }
76
+ return (await sha256Hex(`${row.value}|${visitorIp(req)}`)).slice(0, 32);
77
+ }
78
+ export function createD1Shop(db, options = {}) {
79
+ const T = { orders: tableName(options.tables?.orders, "orders"), owned: tableName(options.tables?.owned, "owned"), meta: tableName(options.tables?.meta, "meta") };
80
+ const COLS = "id, player, look_id, usd, rate, amount, amount_lock, status, created_at, expires_at, hold_until, hash, payer, paid_at, visitor";
81
+ async function inChunks(sql, values, col) {
82
+ const out = new Set();
83
+ for (let i = 0; i < values.length; i += CHUNK) {
84
+ const part = values.slice(i, i + CHUNK);
85
+ const rows = (await db.prepare(sql(part.map(() => "?").join(", "))).bind(...part).all()).results;
86
+ for (const r of rows)
87
+ out.add(r[col]);
88
+ }
89
+ return out;
90
+ }
91
+ const tidy = async (now) => {
92
+ await db.batch([
93
+ db.prepare(`UPDATE ${T.orders} SET status = 'expired' WHERE status = 'open' AND expires_at < ?`).bind(now),
94
+ db.prepare(`UPDATE ${T.orders} SET amount_lock = NULL WHERE amount_lock IS NOT NULL AND hold_until < ?`).bind(now),
95
+ ]);
96
+ };
97
+ const waiting = async (player) => 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();
98
+ return {
99
+ tidy,
100
+ heldAmounts: (amounts) => inChunks((m) => `SELECT amount_lock FROM ${T.orders} WHERE amount_lock IN (${m})`, amounts, "amount_lock"),
101
+ async insert(o) {
102
+ try {
103
+ await db
104
+ .prepare(`INSERT INTO ${T.orders} (id, player, look_id, usd, rate, amount, amount_lock, status, created_at, expires_at, hold_until, visitor) VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?)`)
105
+ .bind(o.id, o.player, o.lookId, o.usd, o.rate, o.amount, o.amount, o.createdAt, o.expiresAt, o.holdUntil, o.visitor)
106
+ .run();
107
+ return true;
108
+ }
109
+ catch (e) {
110
+ // Another order took this amount since the read. Any other failure stops.
111
+ if (isUnique(e))
112
+ return false;
113
+ throw e;
114
+ }
115
+ },
116
+ async order(id) {
117
+ const r = await db.prepare(`SELECT ${COLS} FROM ${T.orders} WHERE id = ?`).bind(id).first();
118
+ return r ? orderOf(r) : null;
119
+ },
120
+ async openOrder(player, lookId, until) {
121
+ const r = await db
122
+ .prepare(`SELECT ${COLS} FROM ${T.orders} WHERE player = ? AND look_id = ? AND status = 'open' AND expires_at > ? ORDER BY created_at DESC LIMIT 1`)
123
+ .bind(player, lookId, until)
124
+ .first();
125
+ return r ? orderOf(r) : null;
126
+ },
127
+ async countOpen(player, now) {
128
+ const r = await db.prepare(`SELECT COUNT(*) AS n FROM ${T.orders} WHERE player = ? AND status = 'open' AND expires_at > ?`).bind(player, now).first();
129
+ return Number(r?.n ?? 0);
130
+ },
131
+ async owns(player, lookId) {
132
+ return Boolean(await db.prepare(`SELECT 1 AS one FROM ${T.owned} WHERE player = ? AND look_id = ?`).bind(player, lookId).first());
133
+ },
134
+ async owned(player) {
135
+ return (await db.prepare(`SELECT look_id FROM ${T.owned} WHERE player = ? ORDER BY granted_at, look_id`).bind(player).all()).results.map((r) => r.look_id);
136
+ },
137
+ async held() {
138
+ return (await db.prepare(`SELECT ${COLS} FROM ${T.orders} WHERE amount_lock IS NOT NULL`).all()).results.map(orderOf);
139
+ },
140
+ usedHashes: (hashes) => inChunks((m) => `SELECT hash FROM ${T.orders} WHERE hash IN (${m})`, hashes, "hash"),
141
+ async settle(order, pay, now) {
142
+ const status = paidStatus(order, pay.seenMs);
143
+ try {
144
+ const [update] = await db.batch([
145
+ db
146
+ .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`)
147
+ .bind(status, pay.hash, pay.payer, pay.seenMs, order.id),
148
+ db
149
+ .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 = ?`)
150
+ .bind(now, order.id, pay.hash),
151
+ ]);
152
+ return changes(update) === 1 ? status : "taken";
153
+ }
154
+ catch (e) {
155
+ if (isUnique(e))
156
+ return "used";
157
+ throw e;
158
+ }
159
+ },
160
+ async claimScan(now, gapMs) {
161
+ const r = await db
162
+ .prepare(`INSERT INTO ${T.meta} (key, value, at) VALUES ('scan', '', ?) ON CONFLICT (key) DO UPDATE SET at = excluded.at WHERE ${T.meta}.at <= ?`)
163
+ .bind(now, now - gapMs)
164
+ .run();
165
+ return changes(r) === 1;
166
+ },
167
+ async readRate() {
168
+ const r = await db.prepare(`SELECT value, at FROM ${T.meta} WHERE key = 'rate'`).first();
169
+ return r ? parseRate(r.value, Number(r.at)) : null;
170
+ },
171
+ async saveRate(rate) {
172
+ await db
173
+ .prepare(`INSERT INTO ${T.meta} (key, value, at) VALUES ('rate', ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at`)
174
+ .bind(JSON.stringify({ usd: rate.usd, sources: rate.sources }), rate.at)
175
+ .run();
176
+ },
177
+ async forget(player, now) {
178
+ await tidy(now);
179
+ const w = await waiting(player);
180
+ if (Number(w?.n ?? 0) > 0)
181
+ return { deleted: 0, kept: 0, waitUntil: Number(w.until) };
182
+ // One batch, and each statement holds the money gate again: an order made meanwhile stops every one of them.
183
+ const gate = `NOT EXISTS (SELECT 1 FROM ${T.orders} WHERE player = ?1 AND amount_lock IS NOT NULL)`;
184
+ const done = await db.batch([
185
+ db.prepare(`DELETE FROM ${T.owned} WHERE player = ?1 AND ${gate}`).bind(player),
186
+ db.prepare(`DELETE FROM ${T.orders} WHERE player = ?1 AND hash IS NULL AND amount_lock IS NULL AND ${gate}`).bind(player),
187
+ // A paid order keeps no payer; a paid-late one keeps it: the creator sends that payment back by hand.
188
+ db.prepare(`UPDATE ${T.orders} SET payer = ?2 WHERE payer = ?1 AND status = 'paid' AND ${gate}`).bind(player, FORGOTTEN),
189
+ db.prepare(`UPDATE ${T.orders} SET player = ?2, visitor = NULL WHERE player = ?1 AND hash IS NOT NULL AND ${gate}`).bind(player, FORGOTTEN),
190
+ ]);
191
+ const [owned, orders, , kept] = done.map(changes);
192
+ if (owned + orders + kept === 0) {
193
+ const again = await waiting(player);
194
+ if (Number(again?.n ?? 0) > 0)
195
+ return { deleted: 0, kept: 0, waitUntil: Number(again.until) };
196
+ }
197
+ return { deleted: owned + orders, kept, waitUntil: null };
198
+ },
199
+ };
200
+ }
@@ -0,0 +1,53 @@
1
+ import { type ShopStore } from "../store.js";
2
+ export type ShopDocSnap = {
3
+ exists: boolean;
4
+ data(): Record<string, unknown> | undefined;
5
+ };
6
+ export interface ShopDocRef {
7
+ get(): Promise<ShopDocSnap>;
8
+ }
9
+ export type ShopQuerySnap = {
10
+ docs: {
11
+ id: string;
12
+ ref: ShopDocRef;
13
+ data(): Record<string, unknown>;
14
+ }[];
15
+ };
16
+ export interface ShopQuery {
17
+ where(field: string, op: "==" | "<", value: unknown): ShopQuery;
18
+ limit(n: number): ShopQuery;
19
+ get(): Promise<ShopQuerySnap>;
20
+ }
21
+ export interface ShopCollection extends ShopQuery {
22
+ doc(id: string): ShopDocRef;
23
+ }
24
+ /** A transaction: every read before the first write, as Firestore asks. */
25
+ export interface ShopTransaction {
26
+ get(ref: ShopDocRef): Promise<ShopDocSnap>;
27
+ set(ref: ShopDocRef, data: Record<string, unknown>, options?: {
28
+ merge?: boolean;
29
+ }): unknown;
30
+ delete(ref: ShopDocRef): unknown;
31
+ }
32
+ export interface ShopBatch {
33
+ set(ref: ShopDocRef, data: Record<string, unknown>, options?: {
34
+ merge?: boolean;
35
+ }): unknown;
36
+ delete(ref: ShopDocRef): unknown;
37
+ commit(): Promise<unknown>;
38
+ }
39
+ export interface FirestoreShopLike {
40
+ collection(name: string): ShopCollection;
41
+ runTransaction<T>(fn: (tx: ShopTransaction) => Promise<T>): Promise<T>;
42
+ batch(): ShopBatch;
43
+ }
44
+ export type ShopCollections = {
45
+ orders?: string;
46
+ owned?: string;
47
+ meta?: string;
48
+ amounts?: string;
49
+ hashes?: string;
50
+ };
51
+ /** The most documents one query reads: a small game's orders of one player, or its held amounts. */
52
+ export declare const FIRESTORE_SHOP_SCAN = 2000;
53
+ export declare function createFirestoreShop(db: FirestoreShopLike, collections?: ShopCollections): ShopStore;