@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.
@@ -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
+ };