@blamejs/blamejs-shop 0.0.62 → 0.0.65
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.
- package/CHANGELOG.md +6 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/compliance-export.js +614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/email-warmup.js +795 -0
- package/lib/error-log.js +525 -0
- package/lib/index.js +25 -0
- package/lib/invoice-renderer.js +618 -0
- package/lib/live-chat.js +714 -0
- package/lib/metered-usage.js +782 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/split-shipments.js +773 -0
- package/lib/store-credit.js +565 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.captchaGate
|
|
4
|
+
* @title CAPTCHA gate — provider registry + token verification at
|
|
5
|
+
* high-risk entry points
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Bot pressure on the storefront concentrates on a small set of
|
|
9
|
+
* entry points: signup (account-creation abuse, coupon scraping),
|
|
10
|
+
* password reset (account takeover prep), checkout-with-coupon
|
|
11
|
+
* (carding + coupon-mining), contact form (spam). The CAPTCHA gate
|
|
12
|
+
* is the primitive operators wire into each of those flows: a
|
|
13
|
+
* provider is registered once (Cloudflare Turnstile, hCaptcha,
|
|
14
|
+
* reCAPTCHA v2, or reCAPTCHA v3), the buyer-submitted token is
|
|
15
|
+
* handed to `verifyToken`, and the outcome is recorded for the
|
|
16
|
+
* provider's audit log.
|
|
17
|
+
*
|
|
18
|
+
* The shape:
|
|
19
|
+
*
|
|
20
|
+
* var cg = bShop.captchaGate.create({ query: q });
|
|
21
|
+
*
|
|
22
|
+
* await cg.registerProvider({
|
|
23
|
+
* slug: "turnstile",
|
|
24
|
+
* kind: "turnstile",
|
|
25
|
+
* public_key: "0x4AAAAAAA...",
|
|
26
|
+
* secret_key: "0x4AAAAAAA...secret",
|
|
27
|
+
* active: true,
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* // The verify callback is the operator's worker: it talks HTTPS
|
|
31
|
+
* // to the provider's siteverify endpoint and returns the parsed
|
|
32
|
+
* // response. The primitive does not perform the HTTP call itself
|
|
33
|
+
* // — each provider has a different URL + payload + response
|
|
34
|
+
* // shape, and the application tier already owns the egress
|
|
35
|
+
* // policy.
|
|
36
|
+
* var r = await cg.verifyToken({
|
|
37
|
+
* provider_slug: "turnstile",
|
|
38
|
+
* token: submittedToken,
|
|
39
|
+
* verify: async function (ctx) {
|
|
40
|
+
* // ctx: { kind, public_key, secret_key, token, action? }
|
|
41
|
+
* var body = new URLSearchParams({
|
|
42
|
+
* secret: ctx.secret_key,
|
|
43
|
+
* response: ctx.token,
|
|
44
|
+
* });
|
|
45
|
+
* var res = await fetch(
|
|
46
|
+
* "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
|
47
|
+
* { method: "POST", body: body },
|
|
48
|
+
* );
|
|
49
|
+
* return await res.json(); // { success, score?, action?, ... }
|
|
50
|
+
* },
|
|
51
|
+
* });
|
|
52
|
+
* // r: { ok, score?, action_match, reasons: [] }
|
|
53
|
+
*
|
|
54
|
+
* await cg.recordOutcome({
|
|
55
|
+
* provider_slug: "turnstile",
|
|
56
|
+
* gate: "signup",
|
|
57
|
+
* ok: r.ok,
|
|
58
|
+
* score: r.score,
|
|
59
|
+
* session_id: sessionId,
|
|
60
|
+
* });
|
|
61
|
+
*
|
|
62
|
+
* reCAPTCHA v3 is the only kind that returns a score; for that kind
|
|
63
|
+
* `threshold_score` (operator-set, in basis points 0..10000 → 0.0..
|
|
64
|
+
* 1.0) is the floor below which the verification fails even when the
|
|
65
|
+
* provider's own `success` flag is true. Turnstile / hCaptcha /
|
|
66
|
+
* reCAPTCHA v2 gate on `success` alone.
|
|
67
|
+
*
|
|
68
|
+
* The action is checked when `verifyToken({ action })` is supplied:
|
|
69
|
+
* reCAPTCHA v3 stamps each token with the action the page declared
|
|
70
|
+
* at challenge time; a token submitted from a page that declared a
|
|
71
|
+
* different action is rejected with `action_mismatch` (a replay-
|
|
72
|
+
* prevention discipline — a token minted on the contact-form page
|
|
73
|
+
* cannot be reused against a password-reset submit). Providers that
|
|
74
|
+
* don't return an action treat the field as unconstrained.
|
|
75
|
+
*
|
|
76
|
+
* Secret-key handling. The raw secret NEVER lands in storage. The
|
|
77
|
+
* primitive runs every operator-supplied secret through
|
|
78
|
+
* `b.crypto.namespaceHash("captcha-secret", secret)` before any
|
|
79
|
+
* write; the column carries the hash plus the trimmed-whitespace
|
|
80
|
+
* normalized form so a re-paste with an extra newline doesn't
|
|
81
|
+
* create a phantom mismatch. The application tier keeps the raw
|
|
82
|
+
* secret in operator memory (env var, secret-manager) and re-hands
|
|
83
|
+
* it to `verifyToken({ verify })` via the callback's `secret_key`
|
|
84
|
+
* field on every call — the primitive only ever reads the
|
|
85
|
+
* hash to recognise that the registered row matches the supplied
|
|
86
|
+
* secret, never returns the raw value from the database.
|
|
87
|
+
*
|
|
88
|
+
* Session / IP hashing. Both are OPTIONAL on `recordOutcome` — the
|
|
89
|
+
* caller passes already-hashed values (the primitive does NOT do
|
|
90
|
+
* the hashing itself for these because the operator's PII policy
|
|
91
|
+
* chooses the hashing scheme). The expected shape is a hex-encoded
|
|
92
|
+
* SHA3-512 from `b.crypto.namespaceHash`.
|
|
93
|
+
*
|
|
94
|
+
* Composes:
|
|
95
|
+
* - `b.crypto.namespaceHash` — secret-key hashing.
|
|
96
|
+
*
|
|
97
|
+
* Surface:
|
|
98
|
+
* - `registerProvider({ slug, kind, public_key, secret_key,
|
|
99
|
+
* threshold_score?, active? })`
|
|
100
|
+
* → the persisted provider (without the secret).
|
|
101
|
+
* - `verifyToken({ provider_slug, token, action?,
|
|
102
|
+
* expected_score?, verify })`
|
|
103
|
+
* → `{ ok, score?, action_match, reasons: [] }`.
|
|
104
|
+
* - `recordOutcome({ provider_slug, gate, ok, score?, action?,
|
|
105
|
+
* session_id?, ip_hash? })`
|
|
106
|
+
* → the persisted verification row.
|
|
107
|
+
* - `metricsForProvider({ slug, from, to })`
|
|
108
|
+
* → `{ total, passed, failed, pass_rate_bps, score_avg_bps? }`.
|
|
109
|
+
* - `listProviders({ active_only? })`
|
|
110
|
+
* → array of provider rows (without the secret).
|
|
111
|
+
* - `getProvider(slug)`
|
|
112
|
+
* → single provider row, or null.
|
|
113
|
+
* - `archiveProvider(slug)`
|
|
114
|
+
* → soft-delete (sets `archived_at`, flips `active = 0`).
|
|
115
|
+
* - `updateProvider(slug, patch)`
|
|
116
|
+
* → mutate `public_key` / `secret_key` / `threshold_score` /
|
|
117
|
+
* `active` (slug + kind are immutable; create a new
|
|
118
|
+
* provider for a different kind).
|
|
119
|
+
* - `gatesForVerification({ slug, from, to, limit, cursor? })`
|
|
120
|
+
* → keyset-paginated audit walk over verifications for one
|
|
121
|
+
* provider. Cursor shape `<occurred_at>:<id>`.
|
|
122
|
+
*
|
|
123
|
+
* Storage:
|
|
124
|
+
* - `captcha_providers`
|
|
125
|
+
* - `captcha_verifications`
|
|
126
|
+
* (migration `0114_captcha_gate.sql`)
|
|
127
|
+
*
|
|
128
|
+
* @primitive captchaGate
|
|
129
|
+
* @related b.crypto.namespaceHash
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
var SECRET_HASH_NAMESPACE = "captcha-secret";
|
|
133
|
+
|
|
134
|
+
var KINDS = Object.freeze([
|
|
135
|
+
"turnstile",
|
|
136
|
+
"hcaptcha",
|
|
137
|
+
"recaptcha_v2",
|
|
138
|
+
"recaptcha_v3",
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
// Only reCAPTCHA v3 returns a score. The other kinds gate on the
|
|
142
|
+
// provider's boolean `success` field; supplying `threshold_score`
|
|
143
|
+
// against any kind other than recaptcha_v3 is refused at registration
|
|
144
|
+
// time so an operator misconfiguration shows up at boot rather than
|
|
145
|
+
// silently letting every Turnstile token through.
|
|
146
|
+
var KINDS_WITH_SCORE = Object.freeze(["recaptcha_v3"]);
|
|
147
|
+
|
|
148
|
+
var GATES = Object.freeze([
|
|
149
|
+
"signup",
|
|
150
|
+
"password_reset",
|
|
151
|
+
"checkout_coupon",
|
|
152
|
+
"contact_form",
|
|
153
|
+
"other",
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
var SLUG_MAX_LEN = 64;
|
|
157
|
+
var PUBLIC_KEY_MAX_LEN = 512;
|
|
158
|
+
var SECRET_KEY_MAX_LEN = 1024;
|
|
159
|
+
var TOKEN_MAX_LEN = 4096;
|
|
160
|
+
var ACTION_MAX_LEN = 128;
|
|
161
|
+
var HASH_MAX_LEN = 256;
|
|
162
|
+
var SESSION_ID_MAX_LEN = 1024;
|
|
163
|
+
var SCORE_BPS_MAX = 10000; // 1.00 in basis points
|
|
164
|
+
var DEFAULT_LIST_LIMIT = 100;
|
|
165
|
+
var MAX_LIST_LIMIT = 500;
|
|
166
|
+
|
|
167
|
+
var SLUG_RE = /^[a-z](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
168
|
+
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
169
|
+
var ACTION_RE = /^[A-Za-z0-9_./-]+$/;
|
|
170
|
+
|
|
171
|
+
var ALLOWED_PATCH_COLUMNS = Object.freeze([
|
|
172
|
+
"public_key",
|
|
173
|
+
"secret_key",
|
|
174
|
+
"threshold_score",
|
|
175
|
+
"active",
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
// Lazy framework handle — same shape as the rest of the shop
|
|
179
|
+
// primitives; avoids a require cycle that would otherwise arise from
|
|
180
|
+
// importing `./index` at module-eval time.
|
|
181
|
+
var bShop;
|
|
182
|
+
function _b() {
|
|
183
|
+
if (!bShop) bShop = require("./index");
|
|
184
|
+
return bShop.framework;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---- validators --------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
function _slug(s) {
|
|
190
|
+
if (typeof s !== "string" || !s.length) {
|
|
191
|
+
throw new TypeError("captchaGate: slug must be a non-empty string");
|
|
192
|
+
}
|
|
193
|
+
if (s.length > SLUG_MAX_LEN) {
|
|
194
|
+
throw new TypeError("captchaGate: slug must be <= " + SLUG_MAX_LEN + " characters");
|
|
195
|
+
}
|
|
196
|
+
if (!SLUG_RE.test(s)) {
|
|
197
|
+
throw new TypeError(
|
|
198
|
+
"captchaGate: slug must match /^[a-z](?:[a-z0-9-]*[a-z0-9])?$/"
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return s;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function _kind(s) {
|
|
205
|
+
if (typeof s !== "string" || KINDS.indexOf(s) === -1) {
|
|
206
|
+
throw new TypeError(
|
|
207
|
+
"captchaGate: kind must be one of " + KINDS.join(", ") + ", got " +
|
|
208
|
+
JSON.stringify(s)
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return s;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function _gate(s) {
|
|
215
|
+
if (typeof s !== "string" || GATES.indexOf(s) === -1) {
|
|
216
|
+
throw new TypeError(
|
|
217
|
+
"captchaGate: gate must be one of " + GATES.join(", ") + ", got " +
|
|
218
|
+
JSON.stringify(s)
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return s;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function _publicKey(s) {
|
|
225
|
+
if (typeof s !== "string" || !s.length) {
|
|
226
|
+
throw new TypeError("captchaGate: public_key must be a non-empty string");
|
|
227
|
+
}
|
|
228
|
+
if (s.length > PUBLIC_KEY_MAX_LEN) {
|
|
229
|
+
throw new TypeError(
|
|
230
|
+
"captchaGate: public_key must be <= " + PUBLIC_KEY_MAX_LEN + " characters"
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
234
|
+
throw new TypeError("captchaGate: public_key contains control bytes");
|
|
235
|
+
}
|
|
236
|
+
return s;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function _secretKeyRaw(s) {
|
|
240
|
+
if (typeof s !== "string" || !s.length) {
|
|
241
|
+
throw new TypeError("captchaGate: secret_key must be a non-empty string");
|
|
242
|
+
}
|
|
243
|
+
// Normalize trailing whitespace before length-checking so a paste
|
|
244
|
+
// with a stray newline doesn't trip the length gate.
|
|
245
|
+
var trimmed = s.replace(/^\s+|\s+$/g, "");
|
|
246
|
+
if (!trimmed.length) {
|
|
247
|
+
throw new TypeError("captchaGate: secret_key must contain non-whitespace");
|
|
248
|
+
}
|
|
249
|
+
if (trimmed.length > SECRET_KEY_MAX_LEN) {
|
|
250
|
+
throw new TypeError(
|
|
251
|
+
"captchaGate: secret_key must be <= " + SECRET_KEY_MAX_LEN + " characters"
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (CONTROL_BYTE_RE.test(trimmed)) {
|
|
255
|
+
throw new TypeError("captchaGate: secret_key contains control bytes");
|
|
256
|
+
}
|
|
257
|
+
return trimmed;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function _hashSecret(normalizedSecret) {
|
|
261
|
+
return _b().crypto.namespaceHash(SECRET_HASH_NAMESPACE, normalizedSecret);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function _thresholdScore(n, kind) {
|
|
265
|
+
if (n == null) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
if (!Number.isInteger(n) || n < 0 || n > SCORE_BPS_MAX) {
|
|
269
|
+
throw new TypeError(
|
|
270
|
+
"captchaGate: threshold_score must be an integer in [0, " +
|
|
271
|
+
SCORE_BPS_MAX + "] (basis points, 10000 = 1.00)"
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (KINDS_WITH_SCORE.indexOf(kind) === -1) {
|
|
275
|
+
throw new TypeError(
|
|
276
|
+
"captchaGate: threshold_score is only meaningful for kinds " +
|
|
277
|
+
KINDS_WITH_SCORE.join(", ") + " — got kind " + JSON.stringify(kind)
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return n;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function _bool(v, label) {
|
|
284
|
+
if (typeof v !== "boolean") {
|
|
285
|
+
throw new TypeError("captchaGate: " + label + " must be a boolean");
|
|
286
|
+
}
|
|
287
|
+
return v;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function _token(s) {
|
|
291
|
+
if (typeof s !== "string" || !s.length) {
|
|
292
|
+
throw new TypeError("captchaGate: token must be a non-empty string");
|
|
293
|
+
}
|
|
294
|
+
if (s.length > TOKEN_MAX_LEN) {
|
|
295
|
+
throw new TypeError(
|
|
296
|
+
"captchaGate: token must be <= " + TOKEN_MAX_LEN + " characters"
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
300
|
+
throw new TypeError("captchaGate: token contains control bytes");
|
|
301
|
+
}
|
|
302
|
+
return s;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function _action(s) {
|
|
306
|
+
if (s == null) return null;
|
|
307
|
+
if (typeof s !== "string" || !s.length) {
|
|
308
|
+
throw new TypeError(
|
|
309
|
+
"captchaGate: action must be a non-empty string when provided"
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (s.length > ACTION_MAX_LEN) {
|
|
313
|
+
throw new TypeError(
|
|
314
|
+
"captchaGate: action must be <= " + ACTION_MAX_LEN + " characters"
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (!ACTION_RE.test(s)) {
|
|
318
|
+
throw new TypeError(
|
|
319
|
+
"captchaGate: action must match /^[A-Za-z0-9_./-]+$/"
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return s;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function _expectedScore(n) {
|
|
326
|
+
if (n == null) return null;
|
|
327
|
+
if (!Number.isInteger(n) || n < 0 || n > SCORE_BPS_MAX) {
|
|
328
|
+
throw new TypeError(
|
|
329
|
+
"captchaGate: expected_score must be an integer in [0, " +
|
|
330
|
+
SCORE_BPS_MAX + "] (basis points)"
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
return n;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function _optionalHash(s, label) {
|
|
337
|
+
if (s == null || s === "") return null;
|
|
338
|
+
if (typeof s !== "string") {
|
|
339
|
+
throw new TypeError("captchaGate: " + label + " must be a string");
|
|
340
|
+
}
|
|
341
|
+
if (s.length > HASH_MAX_LEN) {
|
|
342
|
+
throw new TypeError(
|
|
343
|
+
"captchaGate: " + label + " must be <= " + HASH_MAX_LEN + " characters"
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
347
|
+
throw new TypeError(
|
|
348
|
+
"captchaGate: " + label + " contains control bytes"
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
return s;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function _optionalSessionId(s) {
|
|
355
|
+
if (s == null || s === "") return null;
|
|
356
|
+
if (typeof s !== "string") {
|
|
357
|
+
throw new TypeError("captchaGate: session_id must be a string");
|
|
358
|
+
}
|
|
359
|
+
if (s.length > SESSION_ID_MAX_LEN) {
|
|
360
|
+
throw new TypeError(
|
|
361
|
+
"captchaGate: session_id must be <= " + SESSION_ID_MAX_LEN + " characters"
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
365
|
+
throw new TypeError("captchaGate: session_id contains control bytes");
|
|
366
|
+
}
|
|
367
|
+
return s;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function _hashSession(rawSessionId) {
|
|
371
|
+
return _b().crypto.namespaceHash("captcha-session", rawSessionId);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function _tsBound(n, label) {
|
|
375
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
376
|
+
throw new TypeError(
|
|
377
|
+
"captchaGate: " + label + " must be a non-negative integer (ms epoch)"
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
return n;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function _limit(n, label) {
|
|
384
|
+
if (n == null) return DEFAULT_LIST_LIMIT;
|
|
385
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
386
|
+
throw new TypeError(
|
|
387
|
+
"captchaGate: " + label + " must be an integer in [1, " +
|
|
388
|
+
MAX_LIST_LIMIT + "]"
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return n;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Monotonically-non-decreasing now() — same discipline as
|
|
395
|
+
// sms-dispatcher / order primitives. Two writes in the same
|
|
396
|
+
// millisecond would otherwise collide on the (occurred_at, id) keyset
|
|
397
|
+
// cursor.
|
|
398
|
+
var _lastTs = 0;
|
|
399
|
+
function _now() {
|
|
400
|
+
var t = Date.now();
|
|
401
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
402
|
+
_lastTs = t;
|
|
403
|
+
return t;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---- row hydration -----------------------------------------------------
|
|
407
|
+
|
|
408
|
+
function _hydrateProvider(row) {
|
|
409
|
+
if (!row) return null;
|
|
410
|
+
return {
|
|
411
|
+
slug: row.slug,
|
|
412
|
+
kind: row.kind,
|
|
413
|
+
public_key: row.public_key,
|
|
414
|
+
secret_key_hash: row.secret_key_hash,
|
|
415
|
+
threshold_score: row.threshold_score == null
|
|
416
|
+
? null : Number(row.threshold_score),
|
|
417
|
+
active: Number(row.active) === 1,
|
|
418
|
+
archived_at: row.archived_at == null
|
|
419
|
+
? null : Number(row.archived_at),
|
|
420
|
+
created_at: Number(row.created_at),
|
|
421
|
+
updated_at: Number(row.updated_at),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function _hydrateVerification(row) {
|
|
426
|
+
if (!row) return null;
|
|
427
|
+
return {
|
|
428
|
+
id: Number(row.id),
|
|
429
|
+
provider_slug: row.provider_slug,
|
|
430
|
+
gate: row.gate,
|
|
431
|
+
ok: Number(row.ok) === 1,
|
|
432
|
+
score: row.score == null ? null : Number(row.score),
|
|
433
|
+
action: row.action == null ? null : row.action,
|
|
434
|
+
session_id_hash: row.session_id_hash == null ? null : row.session_id_hash,
|
|
435
|
+
ip_hash: row.ip_hash == null ? null : row.ip_hash,
|
|
436
|
+
occurred_at: Number(row.occurred_at),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---- factory -----------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
function create(opts) {
|
|
443
|
+
opts = opts || {};
|
|
444
|
+
var query = opts.query;
|
|
445
|
+
if (!query) {
|
|
446
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function _getProviderRow(slug) {
|
|
450
|
+
var r = await query(
|
|
451
|
+
"SELECT * FROM captcha_providers WHERE slug = ?1",
|
|
452
|
+
[slug],
|
|
453
|
+
);
|
|
454
|
+
return r.rows[0] || null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function getProvider(slug) {
|
|
458
|
+
return _hydrateProvider(await _getProviderRow(_slug(slug)));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function registerProvider(input) {
|
|
462
|
+
if (!input || typeof input !== "object") {
|
|
463
|
+
throw new TypeError(
|
|
464
|
+
"captchaGate.registerProvider: input object required"
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
var slug = _slug(input.slug);
|
|
468
|
+
var kind = _kind(input.kind);
|
|
469
|
+
var publicKey = _publicKey(input.public_key);
|
|
470
|
+
var secret = _secretKeyRaw(input.secret_key);
|
|
471
|
+
var threshold = _thresholdScore(
|
|
472
|
+
input.threshold_score == null ? null : input.threshold_score,
|
|
473
|
+
kind,
|
|
474
|
+
);
|
|
475
|
+
var active = input.active == null ? true : _bool(input.active, "active");
|
|
476
|
+
|
|
477
|
+
var existing = await _getProviderRow(slug);
|
|
478
|
+
if (existing) {
|
|
479
|
+
throw new TypeError(
|
|
480
|
+
"captchaGate.registerProvider: slug " + JSON.stringify(slug) +
|
|
481
|
+
" already exists — use updateProvider"
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
var secretHash = _hashSecret(secret);
|
|
486
|
+
var now = _now();
|
|
487
|
+
|
|
488
|
+
await query(
|
|
489
|
+
"INSERT INTO captcha_providers " +
|
|
490
|
+
"(slug, kind, public_key, secret_key_hash, secret_key_normalized, " +
|
|
491
|
+
" threshold_score, active, archived_at, created_at, updated_at) " +
|
|
492
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?8)",
|
|
493
|
+
[slug, kind, publicKey, secretHash, secret,
|
|
494
|
+
threshold, active ? 1 : 0, now],
|
|
495
|
+
);
|
|
496
|
+
return _hydrateProvider(await _getProviderRow(slug));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function listProviders(listOpts) {
|
|
500
|
+
listOpts = listOpts || {};
|
|
501
|
+
var activeOnly = false;
|
|
502
|
+
if (listOpts.active_only != null) {
|
|
503
|
+
activeOnly = _bool(listOpts.active_only, "active_only");
|
|
504
|
+
}
|
|
505
|
+
var sql, params;
|
|
506
|
+
if (activeOnly) {
|
|
507
|
+
sql = "SELECT * FROM captcha_providers " +
|
|
508
|
+
"WHERE active = 1 AND archived_at IS NULL " +
|
|
509
|
+
"ORDER BY created_at ASC, slug ASC";
|
|
510
|
+
params = [];
|
|
511
|
+
} else {
|
|
512
|
+
sql = "SELECT * FROM captcha_providers " +
|
|
513
|
+
"ORDER BY created_at ASC, slug ASC";
|
|
514
|
+
params = [];
|
|
515
|
+
}
|
|
516
|
+
var rows = (await query(sql, params)).rows;
|
|
517
|
+
var out = [];
|
|
518
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
519
|
+
out.push(_hydrateProvider(rows[i]));
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function archiveProvider(slug) {
|
|
525
|
+
_slug(slug);
|
|
526
|
+
var ts = _now();
|
|
527
|
+
var r = await query(
|
|
528
|
+
"UPDATE captcha_providers SET archived_at = ?1, active = 0, " +
|
|
529
|
+
"updated_at = ?1 WHERE slug = ?2 AND archived_at IS NULL",
|
|
530
|
+
[ts, slug],
|
|
531
|
+
);
|
|
532
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
533
|
+
var existing = await _getProviderRow(slug);
|
|
534
|
+
if (!existing) {
|
|
535
|
+
throw new TypeError(
|
|
536
|
+
"captchaGate.archiveProvider: slug " + JSON.stringify(slug) +
|
|
537
|
+
" not found"
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
// Already archived — idempotent return so a second archive
|
|
541
|
+
// sweep doesn't have to special-case the slug.
|
|
542
|
+
return _hydrateProvider(existing);
|
|
543
|
+
}
|
|
544
|
+
return _hydrateProvider(await _getProviderRow(slug));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function updateProvider(slug, patch) {
|
|
548
|
+
_slug(slug);
|
|
549
|
+
if (!patch || typeof patch !== "object") {
|
|
550
|
+
throw new TypeError(
|
|
551
|
+
"captchaGate.updateProvider: patch object required"
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
var keys = Object.keys(patch);
|
|
555
|
+
if (!keys.length) {
|
|
556
|
+
throw new TypeError(
|
|
557
|
+
"captchaGate.updateProvider: patch must include at least one column"
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
var current = await _getProviderRow(slug);
|
|
561
|
+
if (!current) {
|
|
562
|
+
throw new TypeError(
|
|
563
|
+
"captchaGate.updateProvider: slug " + JSON.stringify(slug) +
|
|
564
|
+
" not found"
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
var sets = [];
|
|
569
|
+
var params = [];
|
|
570
|
+
var idx = 1;
|
|
571
|
+
var i;
|
|
572
|
+
|
|
573
|
+
for (i = 0; i < keys.length; i += 1) {
|
|
574
|
+
var col = keys[i];
|
|
575
|
+
if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
|
|
576
|
+
throw new TypeError(
|
|
577
|
+
"captchaGate.updateProvider: unsupported column " +
|
|
578
|
+
JSON.stringify(col)
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
if (col === "public_key") {
|
|
582
|
+
sets.push("public_key = ?" + idx);
|
|
583
|
+
params.push(_publicKey(patch[col]));
|
|
584
|
+
} else if (col === "secret_key") {
|
|
585
|
+
var sec = _secretKeyRaw(patch[col]);
|
|
586
|
+
sets.push("secret_key_hash = ?" + idx);
|
|
587
|
+
params.push(_hashSecret(sec));
|
|
588
|
+
idx += 1;
|
|
589
|
+
sets.push("secret_key_normalized = ?" + idx);
|
|
590
|
+
params.push(sec);
|
|
591
|
+
} else if (col === "threshold_score") {
|
|
592
|
+
var resolved = _thresholdScore(
|
|
593
|
+
patch[col] == null ? null : patch[col],
|
|
594
|
+
current.kind,
|
|
595
|
+
);
|
|
596
|
+
sets.push("threshold_score = ?" + idx);
|
|
597
|
+
params.push(resolved);
|
|
598
|
+
} else /* active */ {
|
|
599
|
+
sets.push("active = ?" + idx);
|
|
600
|
+
params.push(_bool(patch[col], "active") ? 1 : 0);
|
|
601
|
+
}
|
|
602
|
+
idx += 1;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
sets.push("updated_at = ?" + idx);
|
|
606
|
+
params.push(_now());
|
|
607
|
+
idx += 1;
|
|
608
|
+
params.push(slug);
|
|
609
|
+
|
|
610
|
+
var r = await query(
|
|
611
|
+
"UPDATE captcha_providers SET " + sets.join(", ") +
|
|
612
|
+
" WHERE slug = ?" + idx,
|
|
613
|
+
params,
|
|
614
|
+
);
|
|
615
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
616
|
+
throw new TypeError(
|
|
617
|
+
"captchaGate.updateProvider: slug " + JSON.stringify(slug) +
|
|
618
|
+
" not found"
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
return _hydrateProvider(await _getProviderRow(slug));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// verifyToken — local-side decision wrapping the operator's worker
|
|
625
|
+
// callback. The callback talks HTTPS to the provider's siteverify
|
|
626
|
+
// endpoint and returns the parsed response; the primitive computes
|
|
627
|
+
// the local-side `ok` (provider `success` AND score-threshold AND
|
|
628
|
+
// action-equality) and returns `reasons` enumerating every gate
|
|
629
|
+
// that contributed to the decision.
|
|
630
|
+
async function verifyToken(input) {
|
|
631
|
+
if (!input || typeof input !== "object") {
|
|
632
|
+
throw new TypeError(
|
|
633
|
+
"captchaGate.verifyToken: input object required"
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
var slug = _slug(input.provider_slug);
|
|
637
|
+
var token = _token(input.token);
|
|
638
|
+
var action = _action(input.action);
|
|
639
|
+
var expScore = _expectedScore(input.expected_score);
|
|
640
|
+
var verify = input.verify;
|
|
641
|
+
if (typeof verify !== "function") {
|
|
642
|
+
throw new TypeError(
|
|
643
|
+
"captchaGate.verifyToken: verify must be a function (operator's worker)"
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
var row = await _getProviderRow(slug);
|
|
648
|
+
if (!row) {
|
|
649
|
+
throw new TypeError(
|
|
650
|
+
"captchaGate.verifyToken: provider " + JSON.stringify(slug) +
|
|
651
|
+
" not found"
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
if (Number(row.active) !== 1 || row.archived_at != null) {
|
|
655
|
+
return {
|
|
656
|
+
ok: false,
|
|
657
|
+
score: null,
|
|
658
|
+
action_match: false,
|
|
659
|
+
reasons: ["provider_inactive"],
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
var ctx = {
|
|
664
|
+
kind: row.kind,
|
|
665
|
+
public_key: row.public_key,
|
|
666
|
+
secret_key: row.secret_key_normalized,
|
|
667
|
+
token: token,
|
|
668
|
+
action: action,
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
var provResponse;
|
|
672
|
+
try {
|
|
673
|
+
provResponse = await verify(ctx);
|
|
674
|
+
} catch (_e) {
|
|
675
|
+
return {
|
|
676
|
+
ok: false,
|
|
677
|
+
score: null,
|
|
678
|
+
action_match: false,
|
|
679
|
+
reasons: ["verify_callback_threw"],
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
if (!provResponse || typeof provResponse !== "object") {
|
|
683
|
+
return {
|
|
684
|
+
ok: false,
|
|
685
|
+
score: null,
|
|
686
|
+
action_match: false,
|
|
687
|
+
reasons: ["verify_callback_returned_non_object"],
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
var reasons = [];
|
|
692
|
+
|
|
693
|
+
// Provider boolean. Every provider's siteverify response carries a
|
|
694
|
+
// `success` boolean; refusing on anything but `true` is the floor
|
|
695
|
+
// gate the rest of the decision composes on top of.
|
|
696
|
+
var provOk = provResponse.success === true;
|
|
697
|
+
if (!provOk) {
|
|
698
|
+
reasons.push("provider_rejected");
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Score (reCAPTCHA v3 only). Provider returns 0.0..1.0 as a JSON
|
|
702
|
+
// number; convert to basis points so the audit log column stays
|
|
703
|
+
// integer. Operators register a threshold (in basis points) at the
|
|
704
|
+
// provider; an optional `expected_score` on the call overrides the
|
|
705
|
+
// registered threshold for the single verification (e.g. a higher
|
|
706
|
+
// bar on password reset than on signup).
|
|
707
|
+
var scoreBps = null;
|
|
708
|
+
if (row.kind === "recaptcha_v3") {
|
|
709
|
+
if (typeof provResponse.score === "number" && isFinite(provResponse.score)) {
|
|
710
|
+
// Floor to keep the integer audit column comparable across
|
|
711
|
+
// requests; provider scores are 0.1 granularity in practice.
|
|
712
|
+
scoreBps = Math.max(0, Math.min(SCORE_BPS_MAX, Math.floor(provResponse.score * 10000)));
|
|
713
|
+
}
|
|
714
|
+
var threshold = expScore != null ? expScore
|
|
715
|
+
: row.threshold_score == null ? null : Number(row.threshold_score);
|
|
716
|
+
if (threshold != null) {
|
|
717
|
+
if (scoreBps == null) {
|
|
718
|
+
reasons.push("score_missing");
|
|
719
|
+
} else if (scoreBps < threshold) {
|
|
720
|
+
reasons.push("score_below_threshold");
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Action equality. reCAPTCHA v3 stamps each token with the action
|
|
726
|
+
// the page declared; a token submitted from a page that declared a
|
|
727
|
+
// different action is a replay candidate. `action_match` is the
|
|
728
|
+
// boolean the caller surfaces; `reasons` carries the mismatch
|
|
729
|
+
// when the verifyToken caller supplied an expected action.
|
|
730
|
+
var actionMatch = true;
|
|
731
|
+
if (action != null) {
|
|
732
|
+
var provAction = typeof provResponse.action === "string"
|
|
733
|
+
? provResponse.action : null;
|
|
734
|
+
if (provAction == null || provAction !== action) {
|
|
735
|
+
actionMatch = false;
|
|
736
|
+
reasons.push("action_mismatch");
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
var ok = provOk && actionMatch;
|
|
741
|
+
if (ok && reasons.length > 0) {
|
|
742
|
+
// Score-below-threshold pushed an entry into reasons; that
|
|
743
|
+
// collapses ok to false even when provOk + actionMatch held.
|
|
744
|
+
ok = false;
|
|
745
|
+
}
|
|
746
|
+
if (ok) {
|
|
747
|
+
reasons.push("verified");
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return {
|
|
751
|
+
ok: ok,
|
|
752
|
+
score: scoreBps,
|
|
753
|
+
action_match: actionMatch,
|
|
754
|
+
reasons: reasons,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function recordOutcome(input) {
|
|
759
|
+
if (!input || typeof input !== "object") {
|
|
760
|
+
throw new TypeError(
|
|
761
|
+
"captchaGate.recordOutcome: input object required"
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
var slug = _slug(input.provider_slug);
|
|
765
|
+
var gate = _gate(input.gate);
|
|
766
|
+
var ok = _bool(input.ok, "ok");
|
|
767
|
+
var score = input.score == null ? null : _expectedScore(input.score);
|
|
768
|
+
var action = _action(input.action);
|
|
769
|
+
var ipHash = _optionalHash(input.ip_hash, "ip_hash");
|
|
770
|
+
|
|
771
|
+
// session_id is the raw cookie value; primitive runs it through
|
|
772
|
+
// namespaceHash before any storage touch (same discipline as
|
|
773
|
+
// cookie-consent / sms-dispatcher).
|
|
774
|
+
var sessHash = null;
|
|
775
|
+
if (input.session_id != null && input.session_id !== "") {
|
|
776
|
+
var rawSess = _optionalSessionId(input.session_id);
|
|
777
|
+
sessHash = _hashSession(rawSess);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
var prov = await _getProviderRow(slug);
|
|
781
|
+
if (!prov) {
|
|
782
|
+
throw new TypeError(
|
|
783
|
+
"captchaGate.recordOutcome: provider " + JSON.stringify(slug) +
|
|
784
|
+
" not found"
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
var now = _now();
|
|
789
|
+
var r = await query(
|
|
790
|
+
"INSERT INTO captcha_verifications " +
|
|
791
|
+
"(provider_slug, gate, ok, score, action, session_id_hash, ip_hash, occurred_at) " +
|
|
792
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
793
|
+
[slug, gate, ok ? 1 : 0, score, action, sessHash, ipHash, now],
|
|
794
|
+
);
|
|
795
|
+
var id = r.lastRowId;
|
|
796
|
+
var fetched = await query(
|
|
797
|
+
"SELECT * FROM captcha_verifications WHERE id = ?1",
|
|
798
|
+
[id],
|
|
799
|
+
);
|
|
800
|
+
return _hydrateVerification(fetched.rows[0] || null);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function metricsForProvider(input) {
|
|
804
|
+
if (!input || typeof input !== "object") {
|
|
805
|
+
throw new TypeError(
|
|
806
|
+
"captchaGate.metricsForProvider: input object required"
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
var slug = _slug(input.slug);
|
|
810
|
+
var from = _tsBound(input.from, "from");
|
|
811
|
+
var to = _tsBound(input.to, "to");
|
|
812
|
+
if (to <= from) {
|
|
813
|
+
throw new TypeError(
|
|
814
|
+
"captchaGate.metricsForProvider: to must be > from"
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
var prov = await _getProviderRow(slug);
|
|
819
|
+
if (!prov) {
|
|
820
|
+
throw new TypeError(
|
|
821
|
+
"captchaGate.metricsForProvider: provider " + JSON.stringify(slug) +
|
|
822
|
+
" not found"
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
var r = await query(
|
|
827
|
+
"SELECT ok, score FROM captcha_verifications " +
|
|
828
|
+
"WHERE provider_slug = ?1 AND occurred_at >= ?2 AND occurred_at < ?3",
|
|
829
|
+
[slug, from, to],
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
var total = r.rows.length;
|
|
833
|
+
var passed = 0;
|
|
834
|
+
var failed = 0;
|
|
835
|
+
var scoreSum = 0;
|
|
836
|
+
var scoreN = 0;
|
|
837
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
838
|
+
var row = r.rows[i];
|
|
839
|
+
if (Number(row.ok) === 1) passed += 1;
|
|
840
|
+
else failed += 1;
|
|
841
|
+
if (row.score != null) {
|
|
842
|
+
scoreSum += Number(row.score);
|
|
843
|
+
scoreN += 1;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
var passRateBps = total === 0 ? 0
|
|
847
|
+
: Math.floor((passed * 10000) / total);
|
|
848
|
+
var scoreAvgBps = scoreN === 0 ? null
|
|
849
|
+
: Math.floor(scoreSum / scoreN);
|
|
850
|
+
|
|
851
|
+
return {
|
|
852
|
+
total: total,
|
|
853
|
+
passed: passed,
|
|
854
|
+
failed: failed,
|
|
855
|
+
pass_rate_bps: passRateBps,
|
|
856
|
+
score_avg_bps: scoreAvgBps,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function gatesForVerification(input) {
|
|
861
|
+
if (!input || typeof input !== "object") {
|
|
862
|
+
throw new TypeError(
|
|
863
|
+
"captchaGate.gatesForVerification: input object required"
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
var slug = _slug(input.slug);
|
|
867
|
+
var from = _tsBound(input.from, "from");
|
|
868
|
+
var to = _tsBound(input.to, "to");
|
|
869
|
+
if (to <= from) {
|
|
870
|
+
throw new TypeError(
|
|
871
|
+
"captchaGate.gatesForVerification: to must be > from"
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
var limit = _limit(input.limit, "limit");
|
|
875
|
+
|
|
876
|
+
var cursorAt = null;
|
|
877
|
+
var cursorId = null;
|
|
878
|
+
if (input.cursor != null) {
|
|
879
|
+
if (typeof input.cursor !== "string" || input.cursor.indexOf(":") === -1) {
|
|
880
|
+
throw new TypeError(
|
|
881
|
+
"captchaGate.gatesForVerification: cursor must be of the form '<occurred_at>:<id>'"
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
var ci = input.cursor.indexOf(":");
|
|
885
|
+
var at = parseInt(input.cursor.slice(0, ci), 10);
|
|
886
|
+
var id = parseInt(input.cursor.slice(ci + 1), 10);
|
|
887
|
+
if (!Number.isInteger(at) || at < 0 ||
|
|
888
|
+
!Number.isInteger(id) || id <= 0) {
|
|
889
|
+
throw new TypeError(
|
|
890
|
+
"captchaGate.gatesForVerification: cursor parses to garbage"
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
cursorAt = at;
|
|
894
|
+
cursorId = id;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
var sql, params;
|
|
898
|
+
if (cursorAt == null) {
|
|
899
|
+
sql =
|
|
900
|
+
"SELECT * FROM captcha_verifications " +
|
|
901
|
+
"WHERE provider_slug = ?1 " +
|
|
902
|
+
" AND occurred_at >= ?2 AND occurred_at < ?3 " +
|
|
903
|
+
"ORDER BY occurred_at DESC, id DESC " +
|
|
904
|
+
"LIMIT ?4";
|
|
905
|
+
params = [slug, from, to, limit + 1];
|
|
906
|
+
} else {
|
|
907
|
+
sql =
|
|
908
|
+
"SELECT * FROM captcha_verifications " +
|
|
909
|
+
"WHERE provider_slug = ?1 " +
|
|
910
|
+
" AND occurred_at >= ?2 AND occurred_at < ?3 " +
|
|
911
|
+
" AND (occurred_at < ?4 OR (occurred_at = ?4 AND id < ?5)) " +
|
|
912
|
+
"ORDER BY occurred_at DESC, id DESC " +
|
|
913
|
+
"LIMIT ?6";
|
|
914
|
+
params = [slug, from, to, cursorAt, cursorId, limit + 1];
|
|
915
|
+
}
|
|
916
|
+
var r = await query(sql, params);
|
|
917
|
+
|
|
918
|
+
var rows = [];
|
|
919
|
+
var take = Math.min(limit, r.rows.length);
|
|
920
|
+
for (var i = 0; i < take; i += 1) {
|
|
921
|
+
rows.push(_hydrateVerification(r.rows[i]));
|
|
922
|
+
}
|
|
923
|
+
var nextCursor = null;
|
|
924
|
+
if (r.rows.length > limit) {
|
|
925
|
+
var last = rows[rows.length - 1];
|
|
926
|
+
nextCursor = last.occurred_at + ":" + last.id;
|
|
927
|
+
}
|
|
928
|
+
return { rows: rows, next_cursor: nextCursor };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
return {
|
|
932
|
+
// Constants the operator's authoring code consults.
|
|
933
|
+
KINDS: KINDS.slice(),
|
|
934
|
+
KINDS_WITH_SCORE: KINDS_WITH_SCORE.slice(),
|
|
935
|
+
GATES: GATES.slice(),
|
|
936
|
+
SECRET_HASH_NAMESPACE: SECRET_HASH_NAMESPACE,
|
|
937
|
+
SCORE_BPS_MAX: SCORE_BPS_MAX,
|
|
938
|
+
SLUG_MAX_LEN: SLUG_MAX_LEN,
|
|
939
|
+
DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
|
|
940
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
941
|
+
|
|
942
|
+
registerProvider: registerProvider,
|
|
943
|
+
getProvider: getProvider,
|
|
944
|
+
listProviders: listProviders,
|
|
945
|
+
archiveProvider: archiveProvider,
|
|
946
|
+
updateProvider: updateProvider,
|
|
947
|
+
verifyToken: verifyToken,
|
|
948
|
+
recordOutcome: recordOutcome,
|
|
949
|
+
metricsForProvider: metricsForProvider,
|
|
950
|
+
gatesForVerification: gatesForVerification,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
module.exports = {
|
|
955
|
+
create: create,
|
|
956
|
+
KINDS: KINDS,
|
|
957
|
+
KINDS_WITH_SCORE: KINDS_WITH_SCORE,
|
|
958
|
+
GATES: GATES,
|
|
959
|
+
SECRET_HASH_NAMESPACE: SECRET_HASH_NAMESPACE,
|
|
960
|
+
SCORE_BPS_MAX: SCORE_BPS_MAX,
|
|
961
|
+
};
|