@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,160 @@
1
+ // The conformance suite of the player sign-in's store: a game that writes its own store (another database) runs it, and so proves that its store
2
+ // keeps every rule the package's adapters keep. The package runs it on the memory store, D1 (on SQLite) and Firestore (a fake, and the emulator).
3
+ //
4
+ // import { playerSigninStoreConformance } from "@nano133/nanoterra/player-signin/conformance";
5
+ // for (const c of playerSigninStoreConformance(async () => ({ store: myStore(), dump: () => allMyRowsAsText() }))) it(c.name, c.run);
6
+ //
7
+ // Each case gets a NEW, empty store from `make`. The rules: a request is finished once and delivered once, and a delivery writes its session in the
8
+ // same step; the ways stay apart; a session lasts until its time; a once-only key counts once; "forget" leaves nothing of the player's.
9
+ function check(ok, message) {
10
+ if (!ok)
11
+ throw new Error(message);
12
+ }
13
+ /** An address the suite can find in any text (a store does not check addresses: the handler does). */
14
+ const who = (n) => `nano_3${n}${"x".repeat(59 - n.length)}`;
15
+ const ANN = who("ann");
16
+ const BOB = who("bob");
17
+ const hex = (n, len = 64) => n.toString(16).padStart(len, "0");
18
+ let serial = 0;
19
+ /** A request as a wallet's start makes it: a fresh id each time. */
20
+ function request(at, extra = {}) {
21
+ serial += 1;
22
+ const id = hex(Date.now() * 1000 + serial, 32).slice(-32);
23
+ return { key: id, r: { id, starter: hex(serial + 7), status: "waiting", expiresAt: at + 5 * 60_000, nonce: hex(serial + 99), issuedAt: at, ...extra } };
24
+ }
25
+ const session = (address, at, n, via = "wallet") => ({ idHash: hex(1_000_000 + n + serial++), address, createdAt: at, expiresAt: at + 30 * 86_400_000, via });
26
+ export function playerSigninStoreConformance(make) {
27
+ const cases = [];
28
+ const add = (name, run) => cases.push({ name, run: async () => run(await make(), Date.now()) });
29
+ add("a request reads back as it was made, by its key and by its browser id, and only for its own way", async ({ store }, now) => {
30
+ const { key, r } = request(now, { label: "game.test sign-in ABCD-EFGH" });
31
+ await store.requests("nautilus").create(key, r);
32
+ const back = await store.requests("nautilus").get(key);
33
+ check(back !== null && back.id === r.id && back.starter === r.starter && back.status === "waiting" && back.expiresAt === r.expiresAt && back.label === r.label && back.nonce === r.nonce && back.issuedAt === r.issuedAt && back.account === undefined, `read back: ${JSON.stringify(back)}`);
34
+ const found = await store.requests("nautilus").find(r.id);
35
+ check(found?.key === key && found.request.id === r.id, `find: ${JSON.stringify(found)}`);
36
+ for (const other of ["berry", "nanchat"]) {
37
+ check((await store.requests(other).get(key)) === null, `${other} does not see a nautilus request`);
38
+ check((await store.requests(other).find(r.id)) === null, `${other} does not find a nautilus request`);
39
+ }
40
+ check((await store.requests("nautilus").get(hex(12345, 32))) === null, "an unknown key is null");
41
+ });
42
+ add("NanChat's request is kept under the hash of its nonce and found by the browser's id", async ({ store }, now) => {
43
+ const { r } = request(now);
44
+ const key = hex(424242);
45
+ await store.requests("nanchat").create(key, { ...r, id: "AbCdEfGhIjKlMnOpQrStUvWx" });
46
+ check((await store.requests("nanchat").find("AbCdEfGhIjKlMnOpQrStUvWx"))?.key === key, "found by the browser's id");
47
+ });
48
+ add("a second request with the same key is refused", async ({ store }, now) => {
49
+ const { key, r } = request(now);
50
+ await store.requests("berry").create(key, r);
51
+ let threw = false;
52
+ try {
53
+ await store.requests("berry").create(key, { ...r, starter: hex(1) });
54
+ }
55
+ catch {
56
+ threw = true;
57
+ }
58
+ check(threw, "the second create throws");
59
+ check((await store.requests("berry").get(key))?.starter === r.starter, "the first request is unchanged");
60
+ });
61
+ add("finish signs a waiting request in once, with its account; a second finish, or one on an unknown key, is false", async ({ store }, now) => {
62
+ const { key, r } = request(now);
63
+ await store.requests("berry").create(key, r);
64
+ check(await store.requests("berry").finish(key, ANN), "the first finish");
65
+ check(!(await store.requests("berry").finish(key, BOB)), "a second finish is false");
66
+ const back = await store.requests("berry").get(key);
67
+ check(back?.status === "signedin" && back.account === ANN, `the account stays the first: ${JSON.stringify(back)}`);
68
+ check(!(await store.requests("berry").finish(hex(777, 32), ANN)), "an unknown key");
69
+ check(!(await store.requests("nautilus").finish(key, ANN)), "another way cannot finish it");
70
+ });
71
+ add("two finishes at the same moment: exactly one is true", async ({ store }, now) => {
72
+ const { key, r } = request(now);
73
+ await store.requests("berry").create(key, r);
74
+ const both = await Promise.all([store.requests("berry").finish(key, ANN), store.requests("berry").finish(key, BOB)]);
75
+ check(both.filter(Boolean).length === 1, `results: ${JSON.stringify(both)}`);
76
+ });
77
+ add("deliver: false while waiting; then once, with its session; a second delivery writes no session", async ({ store }, now) => {
78
+ const { key, r } = request(now);
79
+ await store.requests("berry").create(key, r);
80
+ const early = session(ANN, now, 1);
81
+ check(!(await store.deliver("berry", key, early)), "a waiting request is not delivered");
82
+ check((await store.session(early.idHash, now)) === null, "and no session was written");
83
+ await store.requests("berry").finish(key, ANN);
84
+ const s1 = session(ANN, now, 2);
85
+ check(await store.deliver("berry", key, s1), "the first delivery");
86
+ const read = await store.session(s1.idHash, now);
87
+ check(read?.address === ANN && read.via === "wallet" && read.expiresAt === s1.expiresAt && read.createdAt === s1.createdAt, `the session: ${JSON.stringify(read)}`);
88
+ const s2 = session(ANN, now, 3);
89
+ check(!(await store.deliver("berry", key, s2)), "a second delivery is false");
90
+ check((await store.session(s2.idHash, now)) === null, "and wrote no session");
91
+ check(!(await store.deliver("nautilus", key, session(ANN, now, 4))), "another way cannot deliver it");
92
+ check(!(await store.deliver("berry", hex(888, 32), session(ANN, now, 5))), "an unknown key");
93
+ });
94
+ add("two deliveries at the same moment: one is true, and one session is written", async ({ store, dump }, now) => {
95
+ const { key, r } = request(now);
96
+ await store.requests("nautilus").create(key, r);
97
+ await store.requests("nautilus").finish(key, ANN);
98
+ const a = session(ANN, now, 10);
99
+ const b = session(ANN, now, 11);
100
+ const both = await Promise.all([store.deliver("nautilus", key, a), store.deliver("nautilus", key, b)]);
101
+ check(both.filter(Boolean).length === 1, `results: ${JSON.stringify(both)}`);
102
+ const live = [await store.session(a.idHash, now), await store.session(b.idHash, now)].filter(Boolean);
103
+ check(live.length === 1, `sessions written: ${live.length} (${(await dump()).slice(0, 300)})`);
104
+ });
105
+ add("a session reads while it lasts, with its `via`, and not from its end; ending it removes it", async ({ store }, now) => {
106
+ const s = session(BOB, now, 20, "passkey");
107
+ await store.createSession(s);
108
+ const read = await store.session(s.idHash, now);
109
+ check(read?.address === BOB && read.via === "passkey" && read.idHash === s.idHash, `read: ${JSON.stringify(read)}`);
110
+ check((await store.session(s.idHash, s.expiresAt)) === null, "not at its end");
111
+ check((await store.session(s.idHash, s.expiresAt + 1)) === null, "not after it");
112
+ check((await store.session(hex(999), now)) === null, "an unknown id");
113
+ await store.endSession(s.idHash);
114
+ check((await store.session(s.idHash, now)) === null, "ended");
115
+ await store.endSession(s.idHash);
116
+ });
117
+ add("a once-only key counts once; another key counts; two at the same moment: one", async ({ store }, now) => {
118
+ const k = hex(now);
119
+ check(await store.useOnce(k, 600_000, ANN), "the first use");
120
+ check(!(await store.useOnce(k, 600_000, BOB)), "the second use");
121
+ check(await store.useOnce(hex(now + 1), 600_000, ANN), "another key");
122
+ const k2 = hex(now + 2);
123
+ const both = await Promise.all([store.useOnce(k2, 600_000, ANN), store.useOnce(k2, 600_000, ANN)]);
124
+ check(both.filter(Boolean).length === 1, `two at once: ${JSON.stringify(both)}`);
125
+ });
126
+ add("forget leaves nothing of the player's (sessions, signed requests, once-only keys) and every row of another player", async ({ store, dump }, now) => {
127
+ for (const [address, n] of [[ANN, 30], [BOB, 40]]) {
128
+ await store.createSession(session(address, now, n, "passkey"));
129
+ const { key, r } = request(now);
130
+ await store.requests("berry").create(key, r);
131
+ await store.requests("berry").finish(key, address);
132
+ await store.deliver("berry", key, session(address, now, n + 1));
133
+ await store.useOnce(hex(now + n), 600_000, address);
134
+ }
135
+ const before = await dump();
136
+ check(before.includes(ANN) && before.includes(BOB), "both players are in the store");
137
+ const gone = await store.forget(ANN);
138
+ check(gone >= 4, `rows that went: ${gone}`);
139
+ const after = await dump();
140
+ check(!after.includes(ANN), `the player is left in the store: ${after.slice(0, 400)}`);
141
+ check(after.split(BOB).length - 1 >= 4, "the other player's rows stay");
142
+ check((await store.forget(ANN)) === 0, "a second forget finds nothing");
143
+ });
144
+ add("clean deletes ended sessions and requests 2 hours after they ended, and keeps the rest", async ({ store }, now) => {
145
+ const ended = { ...session(ANN, now - 40 * 86_400_000, 50), expiresAt: now - 1 };
146
+ const live = session(ANN, now, 51);
147
+ await store.createSession(ended);
148
+ await store.createSession(live);
149
+ const old = request(now - 3 * 3_600_000);
150
+ const fresh = request(now);
151
+ await store.requests("berry").create(old.key, old.r);
152
+ await store.requests("berry").create(fresh.key, fresh.r);
153
+ await store.clean(now);
154
+ check((await store.session(live.idHash, now)) !== null, "the live session stays");
155
+ check((await store.session(ended.idHash, now - 2)) === null, "the ended session went");
156
+ check((await store.requests("berry").get(old.key)) === null, "the old request went");
157
+ check((await store.requests("berry").get(fresh.key)) !== null, "the fresh request stays");
158
+ });
159
+ return cases;
160
+ }
@@ -0,0 +1,64 @@
1
+ import { type PlayerSession, type PlayerSigninStore, type Via, type Way } from "./types.js";
2
+ /** The address that belongs to nobody (its key is all zeros): Nautilus names it while the game has no address of its own. */
3
+ export declare const EXAMPLE_ADDRESS = "nano_1111111111111111111111111111111111111111111111111111hifc8npp";
4
+ export type PlayerSigninOptions = {
5
+ store: PlayerSigninStore;
6
+ /**
7
+ * A Nano address of the game's own (the starter's RECEIVE_ADDRESS). Nautilus only NAMES it in its request and in the text it signs: no key, no
8
+ * payment. Null or none: the example address.
9
+ */
10
+ siteAddress?: string | null;
11
+ /** Where the sign-in routes are. Default "/api/signin". */
12
+ base?: string;
13
+ /** The sign-out route. Default "/api/signout". */
14
+ signOutPath?: string;
15
+ /** The wallet apps the game offers, in the sheet's order. Default all three: ["berry", "nanchat", "nautilus"]. An empty list offers none. */
16
+ ways?: readonly Way[];
17
+ /** The passkey route. Default true. */
18
+ passkey?: boolean;
19
+ /**
20
+ * The game's host exactly as players see it ("mygame.com", with a port when it has one). Every signed text names it. Default: the host of each
21
+ * request. When it is set, a call to any other host is refused (403).
22
+ */
23
+ domain?: string;
24
+ /** True when the game's admin signs in on the same paths (the starter's `scope: "admin"`): its calls answer null. Default false. */
25
+ sharedPaths?: boolean;
26
+ /** The cookies' names. Default { session: "session", starter: "starter" } (the starter's). On https they get the __Host- prefix. */
27
+ cookies?: {
28
+ session?: string;
29
+ starter?: string;
30
+ };
31
+ /** How long a session lasts, in days. Default 30. */
32
+ sessionDays?: number;
33
+ /** The shared limiter (sign-in starts, passkey sign-ins, NanChat's totals): false means "too many". Default: a fixed window in this copy's memory. */
34
+ limit?: (key: string, limit: number, windowMs: number, now: number) => boolean | Promise<boolean>;
35
+ /** The visitor's key for the limits: a keyed hash of the IP address, never the address. Default: the CF-Connecting-IP header as it is. */
36
+ visitor?: (request: Request) => Promise<string>;
37
+ /** The person check (Turnstile) of a start and a passkey sign-in: true when `token` passes. No hook: no check. */
38
+ personCheck?: (token: unknown, request: Request) => Promise<boolean>;
39
+ /** The time now in ms. Default Date.now. */
40
+ now?: () => number;
41
+ /** Told about a fault (a store). The player gets a plain 500 and never sees it. */
42
+ onError?: (e: unknown) => void;
43
+ };
44
+ export type PlayerSignin = {
45
+ base: string;
46
+ /** Answers a sign-in route, or null when the request is not one (or is the admin's, with `sharedPaths`). */
47
+ handle(request: Request): Promise<Response | null>;
48
+ /** The signed-in player of this request (their Nano address), or null. The shop's and the friends' `player` hook. Never the admin's login. */
49
+ player(request: Request): Promise<string | null>;
50
+ /** The session of this request: the address, when it was made and how. */
51
+ session(request: Request): Promise<PlayerSession | null>;
52
+ /** Makes a new session for `address` (the game's own code exchange: via "nanoterra") and returns the Set-Cookie value to send. */
53
+ signIn(request: Request, address: string, via: Via): Promise<string>;
54
+ /** Ends this request's session and returns the Set-Cookie value that clears the cookie. */
55
+ signOut(request: Request): Promise<string>;
56
+ /** "Delete my data": every session and sign-in row of the player. Returns how many rows went. */
57
+ forget(player: string): Promise<number>;
58
+ /** The clean-up (a cron): old requests, ended sessions, used once-only keys. */
59
+ clean(now?: number): Promise<void>;
60
+ };
61
+ export declare function sha256Hex(text: string): Promise<string>;
62
+ /** A cookie for this site only: on https it gets the __Host- prefix and Secure (the starter's exact form). */
63
+ export declare function cookieHeader(req: Request, name: string, value: string, maxAgeSec: number): string;
64
+ export declare function createPlayerSignin(options: PlayerSigninOptions): PlayerSignin;
@@ -0,0 +1,364 @@
1
+ // The routes of the PLAYER sign-in, on the Web `Request` and `Response` types (a Worker, Firebase Functions, Node). The same paths and answers as the
2
+ // game starter's Worker (its src/worker/signin.ts), so the starter's page keeps working, plus the passkey route:
3
+ //
4
+ // POST {base}/start the browser starts a wallet request: { way, turnstile? } → { way, id, link, walletLink?, code?, expiresAt } and the starter cookie
5
+ // GET {base}/nano/:id Berry Wallet reads the request (no cookie, no Origin)
6
+ // POST {base}/nano/:id Berry Wallet posts its signature: 204 with no body when accepted, 401 and the words when refused
7
+ // POST {base}/nautilus?id= Nautilus posts its signature: always 200 (Nautilus shows only a 200's words)
8
+ // POST {base}/nanchat NanChat's relay server posts the signature: always 200 (NanChat shows the words; a refusal starts "NOT signed in")
9
+ // POST {base}/check the browser asks every 2 seconds: { id, way } → { status: "waiting" | "expired" | "elsewhere" | "used" | "signedin", address? }
10
+ // POST {base}/passkey the passkey purse's signature: { address, at, signature, turnstile? } → { status: "signedin", address } and the session cookie
11
+ // POST {signOut} ends this browser's session: { ok: true }
12
+ //
13
+ // `base` is "/api/signin" and `signOut` "/api/signout" (the starter's). The session cookie is the starter's: `session` (`__Host-session` on https),
14
+ // HttpOnly, SameSite=Lax, 30 days; the database keeps only the SHA-256 of its token. Only the browser that started a wallet request is signed in by it
15
+ // (its starter cookie), once. The passkey's signature names the game's host and counts once within 5 minutes. The server never sees a passkey, a seed
16
+ // or a key: it checks signatures (@nano133/signin) and keeps sessions.
17
+ //
18
+ // A game whose /admin signs in on the same paths (the starter: `scope: "admin"` in the body) passes `sharedPaths: true`: a call for the admin, and a
19
+ // wallet's call for a request this store does not hold, then answer null, and the game's own admin code answers them.
20
+ import { answerNanchat, answerNanoSignin, answerNautilus, checkNanchat, checkNanoSignin, checkNautilus, checkSignatureSignin, nanchatNonce, nanchatReply, NANO_SIGNIN_ACCEPTED, nanoSigninRefusal, nautilusReply, readNanoSignin, SigninError, startNanchat, startNanoSignin, startNautilus, validAddress, WalletRefused, } from "@nano133/signin";
21
+ import { fail, json, memoryLimiter, readLimited, sameOrigin } from "../admin/http.js";
22
+ import { PLAYER_SIGNIN_LIMITS, SESSION_DAYS, STARTER_MAX_AGE_S, TOKEN_PATTERN, viaOf, WAYS } from "./types.js";
23
+ /** The address that belongs to nobody (its key is all zeros): Nautilus names it while the game has no address of its own. */
24
+ export const EXAMPLE_ADDRESS = "nano_1111111111111111111111111111111111111111111111111111hifc8npp";
25
+ const hex = (b) => Array.from(b instanceof Uint8Array ? b : new Uint8Array(b), (x) => x.toString(16).padStart(2, "0")).join("");
26
+ export async function sha256Hex(text) {
27
+ return hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
28
+ }
29
+ const randomHex = (bytes) => hex(crypto.getRandomValues(new Uint8Array(bytes)));
30
+ /** Only a space or a tab around a cookie's name or value is dropped (JS trim() also drops Unicode spaces, which would let another name pass for ours). */
31
+ const trimAscii = (s) => s.replace(/^[ \t]+|[ \t]+$/g, "");
32
+ function cookieValue(req, name) {
33
+ for (const part of (req.headers.get("Cookie") ?? "").split(";")) {
34
+ const i = part.indexOf("=");
35
+ if (i > 0 && trimAscii(part.slice(0, i)) === name) {
36
+ try {
37
+ return decodeURIComponent(trimAscii(part.slice(i + 1)));
38
+ }
39
+ catch {
40
+ // Not valid percent-coding: not ours.
41
+ }
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+ const isHttps = (req) => new URL(req.url).protocol === "https:";
47
+ /** A cookie for this site only: on https it gets the __Host- prefix and Secure (the starter's exact form). */
48
+ export function cookieHeader(req, name, value, maxAgeSec) {
49
+ const https = isHttps(req);
50
+ return `${https ? "__Host-" : ""}${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAgeSec}; HttpOnly; SameSite=Lax${https ? "; Secure" : ""}`;
51
+ }
52
+ const readCookie = (req, name) => cookieValue(req, isHttps(req) ? `__Host-${name}` : name);
53
+ /** What the player reads for a refused passkey sign-in. */
54
+ function passkeyWords(e) {
55
+ if (e.status === 409)
56
+ return "That sign-in was used already. Try again.";
57
+ if (e.status === 401)
58
+ return "The signature does not match. Try again.";
59
+ if (/too old/.test(e.message))
60
+ return "That sign-in is too old. Check the clock of this device, then try again.";
61
+ return "That sign-in could not be read. Try again.";
62
+ }
63
+ const fallbackLimit = memoryLimiter();
64
+ export function createPlayerSignin(options) {
65
+ const base = options.base ?? "/api/signin";
66
+ const signOutPath = options.signOutPath ?? "/api/signout";
67
+ for (const p of [base, signOutPath])
68
+ if (!p.startsWith("/") || p.length < 2 || p.endsWith("/"))
69
+ throw new Error('a path must start with "/" and not end with "/", like "/api/signin"');
70
+ const ways = options.ways ?? WAYS;
71
+ for (const w of ways)
72
+ if (!WAYS.includes(w))
73
+ throw new Error(`no such wallet app: ${String(w)} (the ways are ${WAYS.join(", ")})`);
74
+ const passkeyOn = options.passkey !== false;
75
+ const store = options.store;
76
+ const sessionName = options.cookies?.session ?? "session";
77
+ const starterName = options.cookies?.starter ?? "starter";
78
+ for (const n of [sessionName, starterName])
79
+ if (!/^[A-Za-z0-9_-]{1,40}$/.test(n))
80
+ throw new Error("a cookie's name is letters, digits, _ and - only");
81
+ const sessionMs = Math.max(1, options.sessionDays ?? SESSION_DAYS) * 86_400_000;
82
+ const now = options.now ?? (() => Date.now());
83
+ const limit = options.limit ?? fallbackLimit;
84
+ const memory = memoryLimiter();
85
+ const siteAddress = options.siteAddress && validAddress(options.siteAddress) ? options.siteAddress : EXAMPLE_ADDRESS;
86
+ const report = (e) => {
87
+ try {
88
+ options.onError?.(e);
89
+ }
90
+ catch {
91
+ // nothing more can be done
92
+ }
93
+ };
94
+ const ipOf = (req) => req.headers.get("CF-Connecting-IP") ?? "anyone";
95
+ const visitorOf = async (req) => (options.visitor ? await options.visitor(req) : ipOf(req));
96
+ const person = async (token, req) => (options.personCheck ? options.personCheck(token, req) : true);
97
+ /** Each wallet's options for this request's site: the domain and page come from the address the player opened (or `domain`). */
98
+ function walletsFor(req, at) {
99
+ const url = new URL(req.url);
100
+ const host = options.domain ?? url.host;
101
+ const origin = `${url.protocol}//${host}`;
102
+ const clock = { now: () => at };
103
+ return {
104
+ host,
105
+ berry: { domain: host, uri: `${origin}/`, store: store.requests("berry"), ...clock },
106
+ nautilus: { site: host, siteAddress, store: store.requests("nautilus"), ...clock },
107
+ nanchat: { host, store: store.requests("nanchat"), ...clock },
108
+ requestUrl: (id) => `${origin}${base}/nano/${id}`,
109
+ nautilusUrl: (id) => `${origin}${base}/nautilus?id=${id}`,
110
+ nanchatUrl: `${origin}${base}/nanchat`,
111
+ };
112
+ }
113
+ async function body(req) {
114
+ const text = await readLimited(req, PLAYER_SIGNIN_LIMITS.bodyMax);
115
+ if (text === null)
116
+ return null;
117
+ if (!text)
118
+ return {};
119
+ try {
120
+ const v = JSON.parse(text);
121
+ return v && typeof v === "object" && !Array.isArray(v) ? v : null;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ /** The way a call names; Berry Wallet when it names none (the starter's page sends none for Berry). Null for anything else. */
128
+ const wayOf = (v) => (v === undefined ? (ways.includes("berry") ? "berry" : null) : (ways.find((w) => w === v) ?? null));
129
+ async function newSession(address, via, at) {
130
+ const token = randomHex(32);
131
+ return { token, record: { idHash: await sha256Hex(token), address, createdAt: at, expiresAt: at + sessionMs, via } };
132
+ }
133
+ async function start(req, b, at) {
134
+ const way = wayOf(b.way);
135
+ if (!way)
136
+ return fail(400, "That wallet is not one of this game's.");
137
+ if (!(await limit(`signin|${await visitorOf(req)}`, PLAYER_SIGNIN_LIMITS.signin.limit, PLAYER_SIGNIN_LIMITS.signin.windowMs, at)))
138
+ return fail(429, "Too many sign-ins. Wait a few minutes.");
139
+ if (!(await person(b.turnstile, req)))
140
+ return fail(403, "The person check failed. Try again.");
141
+ const starter = randomHex(32);
142
+ const hash = await sha256Hex(starter);
143
+ const w = walletsFor(req, at);
144
+ let out;
145
+ if (way === "nautilus") {
146
+ const s = await startNautilus(w.nautilus, { starter: hash, answerUrl: w.nautilusUrl });
147
+ out = { way, id: s.id, link: s.link, code: s.code, expiresAt: s.expiresAt };
148
+ }
149
+ else if (way === "nanchat") {
150
+ const s = await startNanchat(w.nanchat, { starter: hash, answerUrl: w.nanchatUrl });
151
+ out = { way, id: s.id, link: s.link, expiresAt: s.expiresAt };
152
+ }
153
+ else {
154
+ const s = await startNanoSignin(w.berry, { starter: hash, requestUrl: w.requestUrl });
155
+ out = { way, id: s.id, link: s.link, walletLink: s.walletLink, expiresAt: s.expiresAt };
156
+ }
157
+ return json(out, 200, { "Set-Cookie": cookieHeader(req, starterName, starter, STARTER_MAX_AGE_S) });
158
+ }
159
+ async function check(req, b, at) {
160
+ const way = wayOf(b.way);
161
+ if (!way)
162
+ return fail(400, "That wallet is not one of this game's.");
163
+ if (typeof b.id !== "string")
164
+ return fail(400, "no request id");
165
+ const cookie = readCookie(req, starterName);
166
+ const starter = cookie && TOKEN_PATTERN.test(cookie) ? await sha256Hex(cookie) : undefined;
167
+ const o = { store: store.requests(way), now: () => at };
168
+ const r = way === "nautilus" ? await checkNautilus(o, b.id, starter) : way === "nanchat" ? await checkNanchat(o, b.id, starter) : await checkNanoSignin(o, b.id, starter);
169
+ if (r.status !== "signedin")
170
+ return json({ status: r.status });
171
+ if (!r.mine || !r.account)
172
+ return json({ status: "elsewhere" });
173
+ // Once: the delivery and the session are one step of the store; a second check of the same request makes no second session.
174
+ const { token, record } = await newSession(r.account, "wallet", at);
175
+ if (!(await store.deliver(way, r.key ?? b.id, record)))
176
+ return json({ status: "used" });
177
+ return json({ status: "signedin", address: r.account }, 200, { "Set-Cookie": cookieHeader(req, sessionName, token, sessionMs / 1000) });
178
+ }
179
+ async function passkey(req, b, at) {
180
+ if (!(await limit(`signin|${await visitorOf(req)}`, PLAYER_SIGNIN_LIMITS.signin.limit, PLAYER_SIGNIN_LIMITS.signin.windowMs, at)))
181
+ return fail(429, "Too many sign-ins. Wait a few minutes.");
182
+ if (!(await person(b.turnstile, req)))
183
+ return fail(403, "The person check failed. Try again.");
184
+ let address;
185
+ try {
186
+ address = await checkSignatureSignin({ domain: walletsFor(req, at).host, useOnce: (k, ttl, a) => store.useOnce(k, ttl, a), now: () => at }, { address: b.address, at: Number(b.at), signature: b.signature });
187
+ }
188
+ catch (e) {
189
+ if (e instanceof SigninError)
190
+ return fail(e.status, passkeyWords(e));
191
+ throw e;
192
+ }
193
+ const { token, record } = await newSession(address, "passkey", at);
194
+ await store.createSession(record);
195
+ return json({ status: "signedin", address }, 200, { "Set-Cookie": cookieHeader(req, sessionName, token, sessionMs / 1000) });
196
+ }
197
+ /** Berry Wallet's GET and POST. They come from the wallet app: no cookie, no Origin. */
198
+ async function berry(req, id, at) {
199
+ if (options.sharedPaths && !(await store.requests("berry").get(id)))
200
+ return null;
201
+ const w = walletsFor(req, at);
202
+ if (req.method === "GET") {
203
+ const request = await readNanoSignin(w.berry, id, w.requestUrl);
204
+ return request ? json(request) : json(nanoSigninRefusal("This sign-in expired."), 404);
205
+ }
206
+ const b = await body(req);
207
+ if (!b)
208
+ return json(nanoSigninRefusal("This answer is not readable."), 401);
209
+ try {
210
+ await answerNanoSignin(w.berry, id, b, w.requestUrl);
211
+ // Berry Wallet shows its success screen only for a 204 with no body.
212
+ return new Response(null, { status: NANO_SIGNIN_ACCEPTED, headers: { "Cache-Control": "no-store" } });
213
+ }
214
+ catch (e) {
215
+ if (e instanceof WalletRefused)
216
+ return json(nanoSigninRefusal(e.message), 401);
217
+ throw e;
218
+ }
219
+ }
220
+ async function nautilus(req, at) {
221
+ const id = new URL(req.url).searchParams.get("id") ?? "";
222
+ if (options.sharedPaths && !(await store.requests("nautilus").get(id)))
223
+ return null;
224
+ const w = walletsFor(req, at);
225
+ const b = await body(req);
226
+ if (!b)
227
+ return json(nautilusReply(w.host, "That answer could not be read."));
228
+ try {
229
+ await answerNautilus(w.nautilus, id, b);
230
+ return json(nautilusReply(w.host));
231
+ }
232
+ catch (e) {
233
+ if (e instanceof WalletRefused)
234
+ return json(nautilusReply(w.host, e.message));
235
+ throw e;
236
+ }
237
+ }
238
+ /** NanChat's answer: its relay server posts every one, so the limits are totals and per nonce, never per visitor. */
239
+ async function nanchat(req, at) {
240
+ const w = walletsFor(req, at);
241
+ const refuse = (words) => json(nanchatReply(w.host, words));
242
+ if (!memory("nanchat", PLAYER_SIGNIN_LIMITS.nanchatRoute.limit, PLAYER_SIGNIN_LIMITS.nanchatRoute.windowMs, at))
243
+ return refuse("too many sign-ins at once. Wait a minute.");
244
+ const b = await body(req);
245
+ if (!b)
246
+ return refuse("that answer could not be read.");
247
+ const nonce = nanchatNonce(w.host, b.message);
248
+ if (nonce && typeof b.account === "string" && typeof b.signature === "string" && /^[0-9A-Fa-f]{128}$/.test(b.signature)) {
249
+ if (!(await limit("nanchat|all", PLAYER_SIGNIN_LIMITS.nanchatAll.limit, PLAYER_SIGNIN_LIMITS.nanchatAll.windowMs, at)))
250
+ return refuse("too many sign-ins at once. Wait a few minutes.");
251
+ if (!(await limit(`nanchat|${nonce}`, PLAYER_SIGNIN_LIMITS.nanchatNonce.limit, PLAYER_SIGNIN_LIMITS.nanchatNonce.windowMs, at)))
252
+ return refuse(`too many tries for this sign-in. Start again on ${w.host}.`);
253
+ }
254
+ try {
255
+ await answerNanchat(w.nanchat, b);
256
+ return json(nanchatReply(w.host));
257
+ }
258
+ catch (e) {
259
+ if (e instanceof WalletRefused)
260
+ return refuse(e.message);
261
+ throw e;
262
+ }
263
+ }
264
+ async function sessionOf(req) {
265
+ const token = readCookie(req, sessionName);
266
+ if (!token || !TOKEN_PATTERN.test(token))
267
+ return null;
268
+ const s = await store.session(await sha256Hex(token), now());
269
+ return s ? { address: s.address, createdAt: s.createdAt, via: viaOf(s.via) } : null;
270
+ }
271
+ async function signOut(req) {
272
+ const token = readCookie(req, sessionName);
273
+ if (token && TOKEN_PATTERN.test(token))
274
+ await store.endSession(await sha256Hex(token));
275
+ return cookieHeader(req, sessionName, "", 0);
276
+ }
277
+ async function handle(req) {
278
+ const url = new URL(req.url);
279
+ const path = url.pathname;
280
+ const berryPath = path.startsWith(`${base}/nano/`) ? /^[0-9a-f]{32}$/.exec(path.slice(base.length + 6))?.[0] : undefined;
281
+ const browserRoutes = [`${base}/start`, `${base}/check`, `${base}/passkey`, signOutPath];
282
+ const mine = browserRoutes.includes(path) || berryPath !== undefined || path === `${base}/nautilus` || path === `${base}/nanchat`;
283
+ if (!mine)
284
+ return null;
285
+ if (path === `${base}/passkey` && !passkeyOn)
286
+ return null;
287
+ try {
288
+ if (options.domain && url.host !== options.domain)
289
+ return fail(403, `This sign-in belongs to ${options.domain}.`);
290
+ const at = now();
291
+ const ip = ipOf(req);
292
+ // The wallet apps' routes: no cookie and no Origin (an app, or NanChat's relay server, posts them: not a browser page).
293
+ if (berryPath !== undefined) {
294
+ if (req.method !== "GET" && req.method !== "POST")
295
+ return fail(405, "Not allowed.", {}, { Allow: "GET, POST" });
296
+ if (!ways.includes("berry"))
297
+ return null;
298
+ if (!memory(`wallet|${ip}`, PLAYER_SIGNIN_LIMITS.wallet.limit, PLAYER_SIGNIN_LIMITS.wallet.windowMs, at))
299
+ return fail(429, "Too many requests.");
300
+ return await berry(req, berryPath, at);
301
+ }
302
+ if (path === `${base}/nautilus`) {
303
+ if (req.method !== "POST" || !ways.includes("nautilus"))
304
+ return null;
305
+ // Nautilus shows the words of a refusal only with HTTP 200.
306
+ if (!memory(`wallet|${ip}`, PLAYER_SIGNIN_LIMITS.wallet.limit, PLAYER_SIGNIN_LIMITS.wallet.windowMs, at))
307
+ return json(nautilusReply(walletsFor(req, at).host, "Too many requests. Wait a minute."));
308
+ return await nautilus(req, at);
309
+ }
310
+ if (path === `${base}/nanchat`) {
311
+ if (req.method !== "POST" || !ways.includes("nanchat"))
312
+ return null;
313
+ return await nanchat(req, at);
314
+ }
315
+ // The browser's own calls: a POST from the game's own page.
316
+ if (req.method !== "POST")
317
+ return fail(405, "Not allowed.", {}, { Allow: "POST" });
318
+ // Read on a copy: a call that is the admin's goes back to the game whole.
319
+ const b = await body(options.sharedPaths ? req.clone() : req);
320
+ if (b && b.scope === "admin")
321
+ return options.sharedPaths ? null : fail(400, "This game has no such sign-in.");
322
+ if (!sameOrigin(req))
323
+ return fail(403, "This call must come from the game's own page.");
324
+ if (path === signOutPath) {
325
+ // A body that is not JSON (an older page) signs out too.
326
+ return json({ ok: true }, 200, { "Set-Cookie": await signOut(req) });
327
+ }
328
+ if (!b)
329
+ return fail(400, "That request is not readable.");
330
+ if (path === `${base}/check`) {
331
+ if (!memory(`poll|${ip}`, PLAYER_SIGNIN_LIMITS.poll.limit, PLAYER_SIGNIN_LIMITS.poll.windowMs, at))
332
+ return fail(429, "Too many requests. Wait a minute.");
333
+ return await check(req, b, at);
334
+ }
335
+ if (path === `${base}/start`)
336
+ return await start(req, b, at);
337
+ return await passkey(req, b, at);
338
+ }
339
+ catch (e) {
340
+ report(e);
341
+ return fail(500, "Something went wrong. Try again.");
342
+ }
343
+ }
344
+ return {
345
+ base,
346
+ handle,
347
+ async player(req) {
348
+ return (await sessionOf(req))?.address ?? null;
349
+ },
350
+ session: sessionOf,
351
+ async signIn(req, address, via) {
352
+ if (!validAddress(address))
353
+ throw new Error("a session is for a whole Nano address");
354
+ if (!["wallet", "passkey", "nanoterra"].includes(via))
355
+ throw new Error("say how the player signed in: wallet, passkey or nanoterra");
356
+ const { token, record } = await newSession(address, via, now());
357
+ await store.createSession(record);
358
+ return cookieHeader(req, sessionName, token, sessionMs / 1000);
359
+ },
360
+ signOut,
361
+ forget: (player) => store.forget(player),
362
+ clean: (at = now()) => store.clean(at),
363
+ };
364
+ }
@@ -0,0 +1,4 @@
1
+ /** True for a preview address that a game leaves for its own domain: `*.workers.dev`. */
2
+ export declare function previewHost(hostname: string): boolean;
3
+ /** What the admin says on a preview address of a game that offers its players a passkey. */
4
+ export declare const PASSKEY_PREVIEW_NOTE = "Passkeys turn on when your game has its own domain.";
@@ -0,0 +1,10 @@
1
+ // Where a passkey may be made. A passkey belongs to one domain for good: one made on a preview address (`*.workers.dev`, where the game starter
2
+ // runs first) is lost when the game moves to its own domain, and the player's looks go with it. So the sheet offers no passkey there by default,
3
+ // and the admin says why. No DOM and no package: the admin panel uses it too.
4
+ /** True for a preview address that a game leaves for its own domain: `*.workers.dev`. */
5
+ export function previewHost(hostname) {
6
+ const host = hostname.toLowerCase().replace(/\.$/, "");
7
+ return host === "workers.dev" || host.endsWith(".workers.dev");
8
+ }
9
+ /** What the admin says on a preview address of a game that offers its players a passkey. */
10
+ export const PASSKEY_PREVIEW_NOTE = "Passkeys turn on when your game has its own domain.";
@@ -0,0 +1,9 @@
1
+ export { EXAMPLE_ADDRESS, cookieHeader, createPlayerSignin, sha256Hex } from "./handler.js";
2
+ export type { PlayerSignin, PlayerSigninOptions } from "./handler.js";
3
+ export { createMemoryPlayerSignin } from "./memory.js";
4
+ export type { MemoryPlayerSignin } from "./memory.js";
5
+ export { checkScheme, schemeVector } from "./scheme.js";
6
+ export { PASSKEY_PREVIEW_NOTE, previewHost } from "./host.js";
7
+ export type { PasskeyScheme } from "./scheme.js";
8
+ export { PLAYER_SIGNIN_LIMITS, SESSION_DAYS, STARTER_MAX_AGE_S, VIAS, WAYS, viaOf } from "./types.js";
9
+ export type { PlayerSession, PlayerSigninStore, RequestStoreLike, SessionRecord, SigninRequest, Via, Way } from "./types.js";
@@ -0,0 +1,15 @@
1
+ // "@nano133/nanoterra/player-signin": the PLAYER sign-in of a game, in one path: a passkey (the passkey purse: a Nano account made in the browser
2
+ // from the player's passkey) and the three wallet apps (Berry Wallet, NanChat, Nautilus). No password, no email, no payment, and the server holds no
3
+ // key: every way is a signature, checked by @nano133/signin (an optional peer: install it with this package).
4
+ //
5
+ // createPlayerSignin the routes (start, check, the wallets' answers, the passkey, sign-out), sessions, `player(request)` for the shop
6
+ // and the friends list, `forget(player)` for "Delete my data"
7
+ // createMemoryPlayerSignin a store in memory, for tests
8
+ // checkScheme, schemeVector the game's frozen passkey scheme, and its fixed vectors
9
+ // (the adapters are "/player-signin/d1" (with its SQL, "/player-signin/d1.sql") and "/player-signin/firestore", the conformance suite
10
+ // "/player-signin/conformance", the browser sheet "/player-signin/panel" and its "/player-signin/style.css")
11
+ export { EXAMPLE_ADDRESS, cookieHeader, createPlayerSignin, sha256Hex } from "./handler.js";
12
+ export { createMemoryPlayerSignin } from "./memory.js";
13
+ export { checkScheme, schemeVector } from "./scheme.js";
14
+ export { PASSKEY_PREVIEW_NOTE, previewHost } from "./host.js";
15
+ export { PLAYER_SIGNIN_LIMITS, SESSION_DAYS, STARTER_MAX_AGE_S, VIAS, WAYS, viaOf } from "./types.js";