@profullstack/coinpay 0.12.1 → 0.15.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.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * An attempt ledger for sites that punish repeated sign-ins.
3
+ *
4
+ * Tax agencies lock an account after a few attempts and restart the lock on
5
+ * any attempt made inside it (MyFTB: "Account Locked ... exceeded the allowed
6
+ * number of attempts", a 30-minute block that starts over). A retry loop turns
7
+ * one lockout into an open-ended one, so every visit to a throttled source is
8
+ * counted BEFORE it is made, and:
9
+ *
10
+ * - at most `perWindow` attempts per `windowMs` (2 per 30 minutes) and
11
+ * `perDay` per 24 hours (4);
12
+ * - a lockout the site shows is recorded and respected until it ends, with no
13
+ * override (not even --force);
14
+ * - nothing here ever re-submits a credential or a code: the person types
15
+ * those, once, in a window they drive.
16
+ *
17
+ * The pure functions take the ledger as data so the CLI (a JSON file under
18
+ * ~/.coinpay/statements) and the server (rows in finance_site_attempts) share
19
+ * the same rules.
20
+ */
21
+
22
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
23
+ import { dirname, join } from 'node:path';
24
+
25
+ export const TAX_THROTTLE = Object.freeze({
26
+ windowMs: 30 * 60_000,
27
+ perWindow: 2,
28
+ perDay: 4,
29
+ /** When the lockout page names no duration. */
30
+ defaultLockoutMinutes: 60,
31
+ /** Added to a duration the site names, because its clock and ours differ. */
32
+ marginMinutes: 5,
33
+ });
34
+
35
+ const DAY_MS = 24 * 60 * 60_000;
36
+
37
+ /**
38
+ * Whether one more attempt is allowed now.
39
+ * `attempts` is a list of ISO timestamps (or {at}), `lockedUntil` an ISO time or null.
40
+ * Returns `{ok: true}` or `{ok: false, reason, retryAt, message}`.
41
+ */
42
+ export function evaluateThrottle({ attempts = [], lockedUntil = null, lockReason = null } = {}, now = new Date(), limits = TAX_THROTTLE) {
43
+ const t = now.getTime();
44
+ if (lockedUntil && Date.parse(lockedUntil) > t) {
45
+ return {
46
+ ok: false,
47
+ reason: 'locked',
48
+ retryAt: new Date(Date.parse(lockedUntil)).toISOString(),
49
+ message: `the site locked this account${lockReason ? ` (${lockReason})` : ''}; CoinPay will not try again before ${new Date(Date.parse(lockedUntil)).toISOString()}`,
50
+ };
51
+ }
52
+ const times = attempts
53
+ .map((a) => Date.parse(typeof a === 'string' ? a : a && a.at))
54
+ .filter((v) => Number.isFinite(v) && v <= t)
55
+ .sort((a, b) => a - b);
56
+ const inDay = times.filter((v) => t - v < DAY_MS);
57
+ if (inDay.length >= limits.perDay) {
58
+ const retryAt = new Date(inDay[inDay.length - limits.perDay] + DAY_MS).toISOString();
59
+ return { ok: false, reason: 'daily_limit', retryAt, message: `${limits.perDay} attempts in the last 24 hours already; next allowed at ${retryAt}` };
60
+ }
61
+ const inWindow = times.filter((v) => t - v < limits.windowMs);
62
+ if (inWindow.length >= limits.perWindow) {
63
+ const retryAt = new Date(inWindow[inWindow.length - limits.perWindow] + limits.windowMs).toISOString();
64
+ return { ok: false, reason: 'window_limit', retryAt, message: `${limits.perWindow} attempts in the last ${Math.round(limits.windowMs / 60_000)} minutes already; next allowed at ${retryAt}` };
65
+ }
66
+ return { ok: true };
67
+ }
68
+
69
+ /**
70
+ * A lockout page, from its visible text: `{minutes}` (null when it names no
71
+ * duration) or null when the page is not one. Generic on purpose: FTB, IRS and
72
+ * ID.me word it differently, and none publishes the markup.
73
+ */
74
+ export function detectLockout(text) {
75
+ const body = String(text || '').replace(/\s+/g, ' ');
76
+ if (!body) return null;
77
+ const locked =
78
+ /\baccount (?:is |has been |was )?(?:temporarily )?(?:locked|disabled|suspended)\b/i.test(body) ||
79
+ /\b(?:exceeded|reached) the (?:allowed|maximum|max) (?:number of )?(?:login |sign-?in |failed )?(?:attempts|tries)\b/i.test(body) ||
80
+ /\btoo many (?:failed |unsuccessful |login |sign-?in )?(?:attempts|tries|requests)\b/i.test(body) ||
81
+ /\b(?:temporarily|currently) (?:locked out|blocked)\b/i.test(body) ||
82
+ /\blocked out\b/i.test(body);
83
+ if (!locked) return null;
84
+ const minutes = /(\d{1,4})\s*(?:-\s*)?(minutes?|mins?|hours?|hrs?)\b/i.exec(body);
85
+ if (!minutes) return { minutes: null };
86
+ const n = Number(minutes[1]);
87
+ return { minutes: /^h/i.test(minutes[2]) ? n * 60 : n };
88
+ }
89
+
90
+ /** How long to stay away after a lockout: the site's own duration plus a margin, or the default. */
91
+ export function lockoutUntil(found, now = new Date(), limits = TAX_THROTTLE, defaultMinutes = limits.defaultLockoutMinutes) {
92
+ const minutes = found && found.minutes ? found.minutes + limits.marginMinutes : defaultMinutes;
93
+ return new Date(now.getTime() + minutes * 60_000).toISOString();
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // The local ledger: ~/.coinpay/statements/throttle.json
98
+ // ---------------------------------------------------------------------------
99
+
100
+ export function throttlePath(home) {
101
+ return join(home, 'throttle.json');
102
+ }
103
+
104
+ /** `{sources: {key: {attempts: [{at, kind}], lockedUntil, lockReason}}}` */
105
+ export function loadThrottle(home) {
106
+ const path = throttlePath(home);
107
+ if (!existsSync(path)) return { sources: {} };
108
+ try {
109
+ const data = JSON.parse(readFileSync(path, 'utf8'));
110
+ return { sources: data && typeof data.sources === 'object' && data.sources ? data.sources : {} };
111
+ } catch {
112
+ // A corrupt ledger must not become "no attempts made": refuse until it is fixed.
113
+ throw new Error(`${path} is not valid JSON; fix or remove it (it records sign-in attempts so CoinPay does not lock your account)`);
114
+ }
115
+ }
116
+
117
+ export function saveThrottle(ledger, home) {
118
+ const path = throttlePath(home);
119
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
120
+ const temp = `${path}.${process.pid}.tmp`;
121
+ writeFileSync(temp, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 });
122
+ renameSync(temp, path);
123
+ chmodSync(path, 0o600);
124
+ }
125
+
126
+ function entry(ledger, key) {
127
+ ledger.sources[key] ||= { attempts: [], lockedUntil: null, lockReason: null };
128
+ return ledger.sources[key];
129
+ }
130
+
131
+ export function checkLocalThrottle(home, key, now = new Date(), limits = TAX_THROTTLE) {
132
+ const source = loadThrottle(home).sources[key];
133
+ return evaluateThrottle(source || {}, now, limits);
134
+ }
135
+
136
+ /**
137
+ * Check, and when allowed record the attempt in the same step, so the visit
138
+ * is counted before it is made. Returns the verdict.
139
+ */
140
+ export function takeLocalAttempt(home, key, kind, now = new Date(), limits = TAX_THROTTLE) {
141
+ const ledger = loadThrottle(home);
142
+ const source = entry(ledger, key);
143
+ const verdict = evaluateThrottle(source, now, limits);
144
+ if (!verdict.ok) return verdict;
145
+ source.attempts = source.attempts.filter((a) => now.getTime() - Date.parse(a.at) < DAY_MS);
146
+ source.attempts.push({ at: now.toISOString(), kind });
147
+ saveThrottle(ledger, home);
148
+ return verdict;
149
+ }
150
+
151
+ export function recordLocalLockout(home, key, until, reason = 'lockout page') {
152
+ const ledger = loadThrottle(home);
153
+ const source = entry(ledger, key);
154
+ if (!source.lockedUntil || Date.parse(until) > Date.parse(source.lockedUntil)) source.lockedUntil = until;
155
+ source.lockReason = String(reason).slice(0, 200);
156
+ saveThrottle(ledger, home);
157
+ return source;
158
+ }