@prosopo/database 3.15.18 → 3.15.20

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.
@@ -1,2253 +1,1829 @@
1
- "use strict";
2
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
- const is = require("@polkadot/util/is");
4
- const common = require("@prosopo/common");
5
- const redisClient = require("@prosopo/redis-client");
6
- const types = require("@prosopo/types");
7
- const typesDatabase = require("@prosopo/types-database");
8
- const redis = require("@prosopo/user-access-policy/redis");
9
- const util = require("@prosopo/util");
10
- const mongo = require("../base/mongo.cjs");
11
- const TWENTY_FOUR_HOURS_IN_MS = 24 * 60 * 60 * 1e3;
12
- const MAX_DOMAIN_SUFFIX_CANDIDATES = 5;
13
- var TableNames = /* @__PURE__ */ ((TableNames2) => {
14
- TableNames2["captcha"] = "captcha";
15
- TableNames2["dataset"] = "dataset";
16
- TableNames2["solution"] = "solution";
17
- TableNames2["commitment"] = "commitment";
18
- TableNames2["usersolution"] = "usersolution";
19
- TableNames2["scheduler"] = "scheduler";
20
- TableNames2["powcaptcha"] = "powcaptcha";
21
- TableNames2["puzzlecaptcha"] = "puzzlecaptcha";
22
- TableNames2["client"] = "client";
23
- TableNames2["session"] = "session";
24
- TableNames2["detector"] = "detector";
25
- TableNames2["decisionMachine"] = "decisionMachine";
26
- TableNames2["clientContextEntropy"] = "clientContextEntropy";
27
- TableNames2["spamEmailDomain"] = "spamEmailDomain";
28
- return TableNames2;
29
- })(TableNames || {});
30
- const PROVIDER_TABLES = [
31
- {
32
- collectionName: "captcha",
33
- modelName: "Captcha",
34
- schema: typesDatabase.CaptchaRecordSchema
35
- },
36
- {
37
- collectionName: "powcaptcha",
38
- modelName: "PowCaptcha",
39
- schema: typesDatabase.PoWCaptchaRecordSchema
40
- },
41
- {
42
- collectionName: "puzzlecaptcha",
43
- modelName: "PuzzleCaptcha",
44
- schema: typesDatabase.PuzzleCaptchaRecordSchema
45
- },
46
- {
47
- collectionName: "dataset",
48
- modelName: "Dataset",
49
- schema: typesDatabase.DatasetRecordSchema
50
- },
51
- {
52
- collectionName: "solution",
53
- modelName: "Solution",
54
- schema: typesDatabase.SolutionRecordSchema
55
- },
56
- {
57
- collectionName: "commitment",
58
- modelName: "UserCommitment",
59
- schema: typesDatabase.UserCommitmentRecordSchema
60
- },
61
- {
62
- collectionName: "usersolution",
63
- modelName: "UserSolution",
64
- schema: typesDatabase.UserSolutionRecordSchema
65
- },
66
- {
67
- collectionName: "scheduler",
68
- modelName: "Scheduler",
69
- schema: typesDatabase.ScheduledTaskRecordSchema
70
- },
71
- {
72
- collectionName: "client",
73
- modelName: "Client",
74
- schema: typesDatabase.ClientRecordSchema
75
- },
76
- {
77
- collectionName: "session",
78
- modelName: "Session",
79
- schema: typesDatabase.SessionRecordSchema
80
- },
81
- {
82
- collectionName: "detector",
83
- modelName: "Detector",
84
- schema: typesDatabase.DetectorRecordSchema
85
- },
86
- {
87
- collectionName: "decisionMachine",
88
- modelName: "DecisionMachine",
89
- schema: typesDatabase.DecisionMachineArtifactRecordSchema
90
- },
91
- {
92
- collectionName: "clientContextEntropy",
93
- modelName: "ClientContextEntropy",
94
- schema: typesDatabase.ClientContextEntropyRecordSchema
95
- },
96
- {
97
- collectionName: "spamEmailDomain",
98
- modelName: "SpamEmailDomain",
99
- schema: typesDatabase.SpamEmailDomainRecordSchema
100
- }
1
+ const require_mongo = require("../base/mongo.cjs");
2
+ let _prosopo_common = require("@prosopo/common");
3
+ let _prosopo_types = require("@prosopo/types");
4
+ let _prosopo_types_database = require("@prosopo/types-database");
5
+ let _polkadot_util_is = require("@polkadot/util/is");
6
+ let _prosopo_redis_client = require("@prosopo/redis-client");
7
+ let _prosopo_user_access_policy_redis = require("@prosopo/user-access-policy/redis");
8
+ let _prosopo_util = require("@prosopo/util");
9
+ //#region src/databases/provider.ts
10
+ var TWENTY_FOUR_HOURS_IN_MS = 1440 * 60 * 1e3;
11
+ var MAX_DOMAIN_SUFFIX_CANDIDATES = 5;
12
+ var PROVIDER_TABLES = [
13
+ {
14
+ collectionName: "captcha",
15
+ modelName: "Captcha",
16
+ schema: _prosopo_types_database.CaptchaRecordSchema
17
+ },
18
+ {
19
+ collectionName: "powcaptcha",
20
+ modelName: "PowCaptcha",
21
+ schema: _prosopo_types_database.PoWCaptchaRecordSchema
22
+ },
23
+ {
24
+ collectionName: "puzzlecaptcha",
25
+ modelName: "PuzzleCaptcha",
26
+ schema: _prosopo_types_database.PuzzleCaptchaRecordSchema
27
+ },
28
+ {
29
+ collectionName: "dataset",
30
+ modelName: "Dataset",
31
+ schema: _prosopo_types_database.DatasetRecordSchema
32
+ },
33
+ {
34
+ collectionName: "solution",
35
+ modelName: "Solution",
36
+ schema: _prosopo_types_database.SolutionRecordSchema
37
+ },
38
+ {
39
+ collectionName: "commitment",
40
+ modelName: "UserCommitment",
41
+ schema: _prosopo_types_database.UserCommitmentRecordSchema
42
+ },
43
+ {
44
+ collectionName: "usersolution",
45
+ modelName: "UserSolution",
46
+ schema: _prosopo_types_database.UserSolutionRecordSchema
47
+ },
48
+ {
49
+ collectionName: "scheduler",
50
+ modelName: "Scheduler",
51
+ schema: _prosopo_types_database.ScheduledTaskRecordSchema
52
+ },
53
+ {
54
+ collectionName: "client",
55
+ modelName: "Client",
56
+ schema: _prosopo_types_database.ClientRecordSchema
57
+ },
58
+ {
59
+ collectionName: "session",
60
+ modelName: "Session",
61
+ schema: _prosopo_types_database.SessionRecordSchema
62
+ },
63
+ {
64
+ collectionName: "detector",
65
+ modelName: "Detector",
66
+ schema: _prosopo_types_database.DetectorRecordSchema
67
+ },
68
+ {
69
+ collectionName: "decisionMachine",
70
+ modelName: "DecisionMachine",
71
+ schema: _prosopo_types_database.DecisionMachineArtifactRecordSchema
72
+ },
73
+ {
74
+ collectionName: "clientContextEntropy",
75
+ modelName: "ClientContextEntropy",
76
+ schema: _prosopo_types_database.ClientContextEntropyRecordSchema
77
+ },
78
+ {
79
+ collectionName: "spamEmailDomain",
80
+ modelName: "SpamEmailDomain",
81
+ schema: _prosopo_types_database.SpamEmailDomainRecordSchema
82
+ }
101
83
  ];
102
- class ProviderDatabase extends mongo.MongoDatabase {
103
- constructor(options) {
104
- super(
105
- options.mongo.url,
106
- options.mongo.dbname,
107
- options.mongo.authSource,
108
- options.logger
109
- );
110
- this.options = options;
111
- this.tables = {};
112
- this.indexesEnsured = false;
113
- this.tables = {};
114
- this.redisAccessRulesConnection = null;
115
- this.redisConnection = null;
116
- this.userAccessRulesStorage = null;
117
- }
118
- setCentralDbStreamer(streamer) {
119
- this.centralStreamer = streamer;
120
- }
121
- hasCentralDbStreamer() {
122
- return this.centralStreamer !== void 0;
123
- }
124
- async connect() {
125
- await super.connect();
126
- this.loadTables();
127
- await this.setupRedis();
128
- }
129
- async setupRedis() {
130
- this.redisConnection = redisClient.connectToRedis({
131
- url: this.options.redis?.url,
132
- password: this.options.redis?.password,
133
- logger: this.logger
134
- });
135
- this.redisAccessRulesConnection = redisClient.setupRedisIndex(
136
- this.redisConnection,
137
- {
138
- ...redis.accessRulesRedisIndex,
139
- name: this.options.redis?.indexName || redis.accessRulesRedisIndex.name
140
- },
141
- this.logger
142
- );
143
- this.userAccessRulesStorage = redis.createRedisAccessRulesStorage(
144
- this.redisAccessRulesConnection,
145
- this.logger
146
- );
147
- }
148
- loadTables() {
149
- const tables = {};
150
- PROVIDER_TABLES.map(({ collectionName, modelName, schema }) => {
151
- if (this.connection) {
152
- tables[collectionName] = this.connection.model(modelName, schema);
153
- }
154
- });
155
- this.tables = tables;
156
- }
157
- getTables() {
158
- if (!this.tables) {
159
- throw new common.ProsopoDBError("DATABASE.TABLES_UNDEFINED", {
160
- context: { failedFuncName: this.getTables.name },
161
- logger: this.logger
162
- });
163
- }
164
- return this.tables;
165
- }
166
- getRedisAccessRulesConnection() {
167
- if (null === this.redisAccessRulesConnection) {
168
- throw new common.ProsopoDBError(
169
- "DATABASE.REDIS_ACCESS_RULES_CONNECTION_UNDEFINED"
170
- );
171
- }
172
- return this.redisAccessRulesConnection;
173
- }
174
- getRedisConnection() {
175
- if (null === this.redisConnection) {
176
- throw new common.ProsopoDBError(
177
- "DATABASE.REDIS_ACCESS_RULES_CONNECTION_UNDEFINED"
178
- );
179
- }
180
- return this.redisConnection;
181
- }
182
- async ensureIndexes() {
183
- const indexPromises = [];
184
- if (!this.indexesEnsured) {
185
- PROVIDER_TABLES.map(({ collectionName }) => {
186
- indexPromises.push(
187
- new Promise((resolve) => {
188
- if (this.connected) {
189
- this.tables[collectionName].collection.dropIndexes().then(() => {
190
- this.tables[collectionName].ensureIndexes().then(() => {
191
- this.logger.info(() => ({
192
- msg: `Indexes ensured for collection ${collectionName}`
193
- }));
194
- resolve();
195
- }).catch((err) => {
196
- this.logger.warn(() => ({
197
- err,
198
- msg: `Error creating indexes for collection ${collectionName}`
199
- }));
200
- resolve();
201
- });
202
- });
203
- } else {
204
- this.logger.info(() => ({
205
- msg: `Skipping index creation for collection ${collectionName} as not connected`
206
- }));
207
- resolve();
208
- }
209
- })
210
- );
211
- });
212
- }
213
- await Promise.all(indexPromises);
214
- this.indexesEnsured = true;
215
- }
216
- getUserAccessRulesStorage() {
217
- if (null === this.userAccessRulesStorage) {
218
- throw new common.ProsopoDBError("DATABASE.USER_ACCESS_RULES_STORAGE_UNDEFINED");
219
- }
220
- return this.userAccessRulesStorage;
221
- }
222
- /**
223
- * @description Load a dataset to the database
224
- * @param {Dataset} dataset
225
- */
226
- async storeDataset(dataset) {
227
- try {
228
- this.logger.debug(() => ({
229
- data: { datasetId: dataset.datasetId },
230
- msg: "Storing dataset in database"
231
- }));
232
- const parsedDataset = types.DatasetWithIdsAndTreeSchema.parse(dataset);
233
- const datasetDoc = {
234
- datasetId: parsedDataset.datasetId,
235
- datasetContentId: parsedDataset.datasetContentId,
236
- format: parsedDataset.format,
237
- contentTree: parsedDataset.contentTree,
238
- solutionTree: parsedDataset.solutionTree
239
- };
240
- const filter = {
241
- datasetId: parsedDataset.datasetId
242
- };
243
- await this.tables.dataset?.updateOne(
244
- filter,
245
- { $set: datasetDoc },
246
- { upsert: true }
247
- );
248
- const captchaDocs = parsedDataset.captchas.map(
249
- ({ solution, ...captcha }, index) => ({
250
- ...captcha,
251
- datasetId: parsedDataset.datasetId,
252
- datasetContentId: parsedDataset.datasetContentId,
253
- index,
254
- solved: !!solution?.length
255
- })
256
- );
257
- this.logger.debug(() => ({
258
- msg: "Inserting captcha records"
259
- }));
260
- if (captchaDocs.length) {
261
- await this.tables?.captcha.bulkWrite(
262
- captchaDocs.map((captchaDoc) => ({
263
- updateOne: {
264
- filter: { captchaId: captchaDoc.captchaId },
265
- // `$setOnInsert` stamps `randomKey` once on the
266
- // initial insert and leaves it untouched on
267
- // re-imports of the same captchaId. See
268
- // `getRandomCaptcha` and the
269
- // providerBackfillCaptchaRandomKey playbook for
270
- // the corresponding read path and one-shot
271
- // backfill of pre-existing rows.
272
- update: {
273
- $set: captchaDoc,
274
- $setOnInsert: { randomKey: Math.random() }
275
- },
276
- upsert: true
277
- }
278
- }))
279
- );
280
- }
281
- const captchaSolutionDocs = parsedDataset.captchas.filter(({ solution }) => solution?.length).map((captcha) => ({
282
- captchaId: captcha.captchaId,
283
- captchaContentId: captcha.captchaContentId,
284
- solution: captcha.solution,
285
- salt: captcha.salt,
286
- datasetId: parsedDataset.datasetId,
287
- datasetContentId: parsedDataset.datasetContentId
288
- }));
289
- this.logger.debug(() => ({
290
- msg: "Inserting solution records"
291
- }));
292
- if (captchaSolutionDocs.length) {
293
- await this.tables?.solution.bulkWrite(
294
- captchaSolutionDocs.map((captchaSolutionDoc) => ({
295
- updateOne: {
296
- filter: { captchaId: captchaSolutionDoc.captchaId },
297
- update: { $set: captchaSolutionDoc },
298
- upsert: true
299
- }
300
- }))
301
- );
302
- }
303
- this.logger.debug(() => ({
304
- msg: "Dataset stored in database"
305
- }));
306
- } catch (err) {
307
- throw new common.ProsopoDBError("DATABASE.DATASET_LOAD_FAILED", {
308
- context: { failedFuncName: this.storeDataset.name, error: err },
309
- logger: this.logger
310
- });
311
- }
312
- }
313
- /** @description Get solutions for a dataset
314
- * @param {string} datasetId
315
- */
316
- async getSolutions(datasetId) {
317
- const filter = { datasetId };
318
- const docs = await this.tables?.solution.find(filter).lean();
319
- return docs ? docs : [];
320
- }
321
- /** @description Get a solution by captcha id
322
- * @param {string} captchaId
323
- */
324
- async getSolutionByCaptchaId(captchaId) {
325
- const filter = { captchaId };
326
- const doc = await this.tables?.solution.findOne(filter).lean();
327
- return doc || null;
328
- }
329
- /** @description Get a dataset from the database
330
- * @param {string} datasetId
331
- */
332
- async getDataset(datasetId) {
333
- const filter = { datasetId };
334
- const datasetDoc = await this.tables?.dataset.findOne(filter).lean();
335
- if (datasetDoc) {
336
- const { datasetContentId, format, contentTree, solutionTree } = datasetDoc;
337
- const captchas = await this.tables?.captcha.find(filter).lean() || [];
338
- const solutions = await this.tables?.solution.find(filter).lean() || [];
339
- const solutionsKeyed = {};
340
- for (const solution of solutions) {
341
- solutionsKeyed[solution.captchaId] = solution;
342
- }
343
- return {
344
- datasetId,
345
- datasetContentId,
346
- format,
347
- contentTree: contentTree || [],
348
- solutionTree: solutionTree || [],
349
- captchas: captchas.map((captchaDoc) => {
350
- const { captchaId, captchaContentId, items, target, salt, solved } = captchaDoc;
351
- const solution = solutionsKeyed[captchaId];
352
- return {
353
- captchaId,
354
- captchaContentId,
355
- solved: !!solved,
356
- salt,
357
- items,
358
- target,
359
- solution: solved && solution ? solution.solution : []
360
- };
361
- })
362
- };
363
- }
364
- throw new common.ProsopoDBError("DATABASE.DATASET_GET_FAILED", {
365
- context: { failedFuncName: this.getDataset.name, datasetId }
366
- });
367
- }
368
- /**
369
- * @description Get random captchas that are solved or not solved
370
- * @param {boolean} solved `true` when captcha is solved
371
- * @param {string} datasetId the id of the data set
372
- * @param {number} size the number of records to be returned
373
- */
374
- async getRandomCaptcha(solved, datasetId, size) {
375
- if (!is.isHex(datasetId)) {
376
- throw new common.ProsopoDBError("DATABASE.INVALID_HASH", {
377
- context: { failedFuncName: this.getRandomCaptcha.name, datasetId }
378
- });
379
- }
380
- const sampleSize = size ? Math.abs(Math.trunc(size)) : 1;
381
- const projection = {
382
- _id: 0,
383
- datasetId: 1,
384
- datasetContentId: 1,
385
- captchaId: 1,
386
- captchaContentId: 1,
387
- items: 1,
388
- target: 1
389
- };
390
- const baseFilter = { datasetId, solved };
391
- const pivot = Math.random();
392
- const head = await this.tables?.captcha.find({ ...baseFilter, randomKey: { $gte: pivot } }, projection).sort({ randomKey: 1 }).limit(sampleSize).lean() ?? [];
393
- let docs = head;
394
- if (docs.length < sampleSize) {
395
- const tail = await this.tables?.captcha.find({ ...baseFilter, randomKey: { $lt: pivot } }, projection).sort({ randomKey: 1 }).limit(sampleSize - docs.length).lean() ?? [];
396
- docs = docs.concat(tail);
397
- }
398
- if (docs.length === 0) {
399
- const fallback = await this.tables?.captcha.aggregate([
400
- { $match: baseFilter },
401
- { $sample: { size: sampleSize } },
402
- { $project: projection }
403
- ]).exec() ?? [];
404
- if (fallback.length > 0) {
405
- return fallback.map(({ _id, ...keep }) => keep);
406
- }
407
- }
408
- if (docs.length > 0) {
409
- return docs;
410
- }
411
- throw new common.ProsopoDBError("DATABASE.CAPTCHA_GET_FAILED", {
412
- context: {
413
- failedFuncName: this.getRandomCaptcha.name,
414
- solved,
415
- datasetId,
416
- size
417
- }
418
- });
419
- }
420
- /**
421
- * @description Get captchas by id
422
- * @param {string[]} captchaId
423
- */
424
- async getCaptchaById(captchaId) {
425
- const filter = { captchaId: { $in: captchaId } };
426
- const cursor = this.tables?.captcha.find(filter, {
427
- _id: 0,
428
- captchaId: 1,
429
- datasetId: 1,
430
- items: 1
431
- }).lean();
432
- const docs = await cursor;
433
- if (docs?.length) {
434
- return docs.map(({ _id, ...keepAttrs }) => keepAttrs);
435
- }
436
- throw new common.ProsopoDBError("DATABASE.CAPTCHA_GET_FAILED", {
437
- context: { failedFuncName: this.getCaptchaById.name, captchaId }
438
- });
439
- }
440
- /**
441
- * @description Update a captcha
442
- * @param {Captcha} captcha
443
- * @param {string} datasetId the id of the data set
444
- */
445
- async updateCaptcha(captcha, datasetId) {
446
- if (!is.isHex(datasetId)) {
447
- throw new common.ProsopoDBError("DATABASE.INVALID_HASH", {
448
- context: { failedFuncName: this.updateCaptcha.name, datasetId }
449
- });
450
- }
451
- try {
452
- const filter = { datasetId };
453
- await this.tables?.captcha.updateOne(
454
- filter,
455
- { $set: captcha },
456
- { upsert: false }
457
- );
458
- } catch (err) {
459
- throw new common.ProsopoDBError("DATABASE.CAPTCHA_UPDATE_FAILED", {
460
- context: { failedFuncName: this.getDatasetDetails.name, error: err }
461
- });
462
- }
463
- }
464
- /**
465
- * @description Remove captchas
466
- */
467
- async removeCaptchas(captchaIds) {
468
- const filter = { captchaId: { $in: captchaIds } };
469
- await this.tables?.captcha.deleteMany(filter);
470
- }
471
- /**
472
- * @description Get a dataset by Id
473
- */
474
- /**
475
- * Returns the most recently uploaded dataset's ID. Used as the per-provider
476
- * default when a client doesn't pin a specific dataset (which it can't
477
- * under DNS-based routing — clients don't know which provider they'll hit).
478
- */
479
- async getMostRecentDatasetId() {
480
- const dataset = await this.tables?.dataset.findOne().sort({ _id: -1 }).lean();
481
- const datasetId = dataset?.datasetId;
482
- return typeof datasetId === "string" ? datasetId : void 0;
483
- }
484
- async getDatasetDetails(datasetId) {
485
- if (!is.isHex(datasetId)) {
486
- throw new common.ProsopoDBError("DATABASE.INVALID_HASH", {
487
- context: { failedFuncName: this.getDatasetDetails.name, datasetId }
488
- });
489
- }
490
- const filter = { datasetId };
491
- const doc = await this.tables?.dataset.findOne(filter).lean();
492
- if (doc) {
493
- return doc;
494
- }
495
- throw new common.ProsopoDBError("DATABASE.DATASET_GET_FAILED", {
496
- context: {
497
- failedFuncName: this.getDatasetDetails.name,
498
- datasetId
499
- }
500
- });
501
- }
502
- /**
503
- * @description Store a Dapp User's captcha solution commitment
504
- */
505
- async storeUserImageCaptchaSolution(captchas, commit) {
506
- const commitmentRecord = types.UserCommitmentSchema.parse({
507
- ...commit,
508
- lastUpdatedTimestamp: /* @__PURE__ */ new Date()
509
- });
510
- if (captchas.length) {
511
- const filter = {
512
- id: commit.id
513
- };
514
- await this.tables?.commitment.updateOne(filter, commitmentRecord, {
515
- upsert: true
516
- });
517
- const ops = captchas.map((captcha) => ({
518
- updateOne: {
519
- filter: {
520
- commitmentId: commit.id,
521
- captchaId: captcha.captchaId
522
- },
523
- update: {
524
- $set: {
525
- captchaId: captcha.captchaId,
526
- captchaContentId: captcha.captchaContentId,
527
- salt: captcha.salt,
528
- solution: captcha.solution,
529
- commitmentId: commit.id,
530
- processed: false
531
- }
532
- },
533
- upsert: true
534
- }
535
- }));
536
- await this.tables?.usersolution.bulkWrite(ops);
537
- this.centralStreamer?.streamImageRecord(
538
- commitmentRecord,
539
- (ts) => this.tables.commitment.updateOne({ id: commit.id }, { $set: { storedAtTimestamp: ts } }).then(() => {
540
- })
541
- );
542
- }
543
- }
544
- /**
545
- * @description Adds a new PoW Captcha record to the database.
546
- * @param {string} challenge The challenge string for the captcha.
547
- * @param components The components of the PoW challenge.
548
- * @param difficulty
549
- * @param providerSignature
550
- * @param ipAddress
551
- * @param headers
552
- * @param ja4
553
- * @param sessionId
554
- * @param serverChecked
555
- * @param userSubmitted
556
- * @param storedStatus
557
- * @param userSignature
558
- * @param ipInfo full ipinfo payload from the ipInfoMiddleware
559
- * @returns {Promise<void>} A promise that resolves when the record is added.
560
- */
561
- async storePowCaptchaRecord(challenge, components, difficulty, providerSignature, ipAddress, headers, ja4, sessionId, serverChecked = false, userSubmitted = false, userSignature, ipInfo) {
562
- const tables = this.getTables();
563
- const powCaptchaRecord = {
564
- challenge,
565
- userAccount: components.userAccount,
566
- dappAccount: components.dappAccount,
567
- requestedAtTimestamp: new Date(components.requestedAtTimestamp),
568
- ipAddress,
569
- headers,
570
- ja4,
571
- result: { status: types.CaptchaStatus.pending },
572
- userSubmitted,
573
- serverChecked,
574
- difficulty,
575
- providerSignature,
576
- userSignature,
577
- lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
578
- pendingStage: true,
579
- sessionId,
580
- ipInfo
581
- };
582
- try {
583
- await tables.powcaptcha.create(powCaptchaRecord);
584
- this.logger.info(() => ({
585
- data: {
586
- challenge,
587
- userSubmitted,
588
- serverChecked,
589
- countryCode: ipInfo?.isValid ? ipInfo.countryCode : void 0
590
- },
591
- msg: "PowCaptcha record added successfully"
592
- }));
593
- this.centralStreamer?.streamPowRecord(
594
- powCaptchaRecord,
595
- (ts) => (
596
- // Guard with `lastUpdatedTimestamp: { $lte: ts }` so a concurrent
597
- // update (newer lastUpdatedTimestamp) doesn't get its pendingStage
598
- // flag cleared by this older stage completion. Mismatched docs
599
- // leave pendingStage set so the next sweep picks them up.
600
- this.tables.powcaptcha.updateOne(
601
- { challenge, lastUpdatedTimestamp: { $lte: ts } },
602
- {
603
- $set: { storedAtTimestamp: ts },
604
- $unset: { pendingStage: 1 }
605
- }
606
- ).then(() => {
607
- })
608
- )
609
- );
610
- } catch (error) {
611
- const err = new common.ProsopoDBError("DATABASE.CAPTCHA_UPDATE_FAILED", {
612
- context: {
613
- error,
614
- challenge,
615
- userSubmitted,
616
- serverChecked,
617
- ipInfo
618
- },
619
- logger: this.logger
620
- });
621
- this.logger.error(() => ({
622
- err: error,
623
- msg: "Failed to add PowCaptcha record"
624
- }));
625
- throw err;
626
- }
627
- }
628
- /**
629
- * @description Retrieves a PoW Captcha record by its challenge string.
630
- * @param {string} challenge The challenge string to search for.
631
- * @returns {Promise<PoWCaptchaRecord | null>} A promise that resolves with the found record or null if not found.
632
- */
633
- async getPowCaptchaRecordByChallenge(challenge) {
634
- if (!this.tables) {
635
- throw new common.ProsopoDBError("DATABASE.DATABASE_UNDEFINED", {
636
- context: { failedFuncName: this.getPowCaptchaRecordByChallenge.name },
637
- logger: this.logger
638
- });
639
- }
640
- try {
641
- const filter = { challenge };
642
- const record = await this.tables.powcaptcha.findOne(filter, {
643
- challenge: 1,
644
- userAccount: 1,
645
- dappAccount: 1,
646
- requestedAtTimestamp: 1,
647
- submittedAtTimestamp: 1,
648
- verifiedAtTimestamp: 1,
649
- failedAtTimestamp: 1,
650
- ipAddress: 1,
651
- headers: 1,
652
- ja4: 1,
653
- result: 1,
654
- difficulty: 1,
655
- sessionId: 1,
656
- ipInfo: 1,
657
- deviceCapability: 1,
658
- behavioralDataPacked: 1,
659
- serverChecked: 1,
660
- userSubmitted: 1,
661
- coords: 1
662
- }).lean();
663
- if (record) {
664
- this.logger.info(() => ({
665
- data: { challenge },
666
- msg: "PowCaptcha record retrieved successfully"
667
- }));
668
- return record;
669
- }
670
- this.logger.info(() => ({
671
- data: { challenge },
672
- msg: "No PowCaptcha record found"
673
- }));
674
- return null;
675
- } catch (error) {
676
- const err = new common.ProsopoDBError("DATABASE.CAPTCHA_GET_FAILED", {
677
- context: { error, challenge },
678
- logger: this.logger
679
- });
680
- this.logger.error(() => ({
681
- err,
682
- msg: "Failed to retrieve PowCaptcha record"
683
- }));
684
- throw err;
685
- }
686
- }
687
- /**
688
- * @description Updates a PoW Captcha record in the database.
689
- * @param {string} challenge The challenge string of the captcha to be updated.
690
- * @param result
691
- * @param serverChecked
692
- * @param userSubmitted
693
- * @param userSignature
694
- * @returns {Promise<void>} A promise that resolves when the record is updated.
695
- */
696
- async updatePowCaptchaRecordResult(challenge, result, serverChecked = false, userSubmitted = false, userSignature, coords) {
697
- const tables = this.getTables();
698
- const timestamp = /* @__PURE__ */ new Date();
699
- const isDisapproved = result.status === types.CaptchaStatus.disapproved;
700
- util.assertCoordsSafe(coords, "coords");
701
- const setStage = {
702
- result,
703
- serverChecked,
704
- userSubmitted,
705
- userSignature,
706
- lastUpdatedTimestamp: timestamp,
707
- pendingStage: true,
708
- ...coords && { coords }
709
- };
710
- if (userSubmitted) {
711
- setStage.submittedAtTimestamp = {
712
- $ifNull: ["$submittedAtTimestamp", timestamp]
713
- };
714
- }
715
- if (isDisapproved) {
716
- setStage.failedAtTimestamp = {
717
- $ifNull: ["$failedAtTimestamp", timestamp]
718
- };
719
- }
720
- try {
721
- const updateResult = await tables.powcaptcha.updateOne({ challenge }, [
722
- { $set: setStage }
723
- ]);
724
- if (updateResult.matchedCount === 0) {
725
- const err = new common.ProsopoDBError("DATABASE.CAPTCHA_GET_FAILED", {
726
- context: {
727
- challenge,
728
- ...setStage
729
- },
730
- logger: this.logger
731
- });
732
- this.logger.info(() => ({
733
- err,
734
- msg: "No PowCaptcha record found to update"
735
- }));
736
- throw err;
737
- }
738
- this.logger.info(() => ({
739
- data: {
740
- challenge,
741
- ...setStage
742
- },
743
- msg: "PowCaptcha record updated successfully"
744
- }));
745
- this.centralStreamer?.streamPowUpdate(
746
- () => this.getPowCaptchaRecordByChallenge(challenge),
747
- (ts) => this.tables.powcaptcha.updateOne(
748
- { challenge, lastUpdatedTimestamp: { $lte: ts } },
749
- {
750
- $set: { storedAtTimestamp: ts },
751
- $unset: { pendingStage: 1 }
752
- }
753
- ).then(() => {
754
- })
755
- );
756
- } catch (error) {
757
- const err = new common.ProsopoDBError("DATABASE.CAPTCHA_UPDATE_FAILED", {
758
- context: {
759
- error,
760
- challenge,
761
- ...setStage
762
- },
763
- logger: this.logger
764
- });
765
- this.logger.error(() => ({
766
- err,
767
- msg: "Failed to update PowCaptcha record"
768
- }));
769
- throw err;
770
- }
771
- }
772
- async updatePowCaptchaRecord(challenge, updates) {
773
- const tables = this.getTables();
774
- await tables.powcaptcha.updateOne(
775
- { challenge },
776
- { $set: { ...updates, pendingStage: true } },
777
- { upsert: false }
778
- );
779
- this.centralStreamer?.streamPowUpdate(
780
- () => this.getPowCaptchaRecordByChallenge(challenge),
781
- (ts) => this.tables.powcaptcha.updateOne(
782
- { challenge, lastUpdatedTimestamp: { $lte: ts } },
783
- {
784
- $set: { storedAtTimestamp: ts },
785
- $unset: { pendingStage: 1 }
786
- }
787
- ).then(() => {
788
- })
789
- );
790
- }
791
- async storePuzzleCaptchaRecord(challenge, components, targetX, targetY, originX, originY, tolerance, providerSignature, ipAddress, headers, ja4, sessionId, ipInfo) {
792
- const tables = this.getTables();
793
- const puzzleCaptchaRecord = {
794
- challenge,
795
- userAccount: components.userAccount,
796
- dappAccount: components.dappAccount,
797
- requestedAtTimestamp: new Date(components.requestedAtTimestamp),
798
- ipAddress,
799
- headers,
800
- ja4,
801
- result: { status: types.CaptchaStatus.pending },
802
- userSubmitted: false,
803
- serverChecked: false,
804
- targetX,
805
- targetY,
806
- originX,
807
- originY,
808
- tolerance,
809
- providerSignature,
810
- lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
811
- pendingStage: true,
812
- sessionId,
813
- ipInfo
814
- };
815
- try {
816
- await tables.puzzlecaptcha.create(puzzleCaptchaRecord);
817
- this.logger.info(() => ({
818
- data: {
819
- challenge,
820
- countryCode: ipInfo?.isValid ? ipInfo.countryCode : void 0
821
- },
822
- msg: "PuzzleCaptcha record added successfully"
823
- }));
824
- this.centralStreamer?.streamPuzzleRecord(
825
- puzzleCaptchaRecord,
826
- (ts) => this.tables.puzzlecaptcha.updateOne(
827
- { challenge, lastUpdatedTimestamp: { $lte: ts } },
828
- {
829
- $set: { storedAtTimestamp: ts },
830
- $unset: { pendingStage: 1 }
831
- }
832
- ).then(() => {
833
- })
834
- );
835
- } catch (error) {
836
- const err = new common.ProsopoDBError("DATABASE.CAPTCHA_UPDATE_FAILED", {
837
- context: {
838
- error,
839
- challenge,
840
- ipInfo
841
- },
842
- logger: this.logger
843
- });
844
- this.logger.error(() => ({
845
- err: error,
846
- msg: "Failed to add PuzzleCaptcha record"
847
- }));
848
- throw err;
849
- }
850
- }
851
- /**
852
- * @description Retrieves a Puzzle Captcha record by its challenge string.
853
- * @param {string} challenge The challenge string to search for.
854
- * @returns {Promise<PuzzleCaptchaRecord | null>} A promise that resolves with the found record or null if not found.
855
- */
856
- async getPuzzleCaptchaRecordByChallenge(challenge) {
857
- if (!this.tables) {
858
- throw new common.ProsopoDBError("DATABASE.DATABASE_UNDEFINED", {
859
- context: {
860
- failedFuncName: this.getPuzzleCaptchaRecordByChallenge.name
861
- },
862
- logger: this.logger
863
- });
864
- }
865
- try {
866
- const filter = { challenge };
867
- const record = await this.tables.puzzlecaptcha.findOne(filter, {
868
- challenge: 1,
869
- userAccount: 1,
870
- dappAccount: 1,
871
- requestedAtTimestamp: 1,
872
- // submittedAtTimestamp gates the submit → verify
873
- // recency check in serverVerifyPuzzleCaptchaSolution.
874
- // Missing it here silently trips the "too old" path
875
- // even on freshly-solved puzzles because the code
876
- // treats a missing field as Number.POSITIVE_INFINITY.
877
- submittedAtTimestamp: 1,
878
- ipAddress: 1,
879
- headers: 1,
880
- ja4: 1,
881
- result: 1,
882
- targetX: 1,
883
- targetY: 1,
884
- originX: 1,
885
- originY: 1,
886
- tolerance: 1,
887
- puzzleEvents: 1,
888
- sessionId: 1,
889
- ipInfo: 1,
890
- deviceCapability: 1,
891
- behavioralDataPacked: 1,
892
- serverChecked: 1,
893
- userSubmitted: 1,
894
- coords: 1
895
- }).lean();
896
- if (record) {
897
- this.logger.info(() => ({
898
- data: { challenge },
899
- msg: "PuzzleCaptcha record retrieved successfully"
900
- }));
901
- return record;
902
- }
903
- this.logger.info(() => ({
904
- data: { challenge },
905
- msg: "No PuzzleCaptcha record found"
906
- }));
907
- return null;
908
- } catch (error) {
909
- const err = new common.ProsopoDBError("DATABASE.CAPTCHA_GET_FAILED", {
910
- context: { error, challenge },
911
- logger: this.logger
912
- });
913
- this.logger.error(() => ({
914
- err,
915
- msg: "Failed to retrieve PuzzleCaptcha record"
916
- }));
917
- throw err;
918
- }
919
- }
920
- /**
921
- * @description Updates a Puzzle Captcha record result in the database.
922
- * @param {string} challenge The challenge string of the captcha to be updated.
923
- * @param result
924
- * @param serverChecked
925
- * @param userSubmitted
926
- * @param userSignature
927
- * @param coords
928
- * @param lastUpdatedTimestamp
929
- * @returns {Promise<void>} A promise that resolves when the record is updated.
930
- */
931
- async updatePuzzleCaptchaRecordResult(challenge, result, serverChecked = false, userSubmitted = false, userSignature, coords, lastUpdatedTimestamp) {
932
- const tables = this.getTables();
933
- const timestamp = lastUpdatedTimestamp ?? /* @__PURE__ */ new Date();
934
- const isDisapproved = result.status === types.CaptchaStatus.disapproved;
935
- util.assertCoordsSafe(coords, "coords");
936
- const setStage = {
937
- result,
938
- serverChecked,
939
- userSubmitted,
940
- userSignature,
941
- lastUpdatedTimestamp: timestamp,
942
- pendingStage: true,
943
- ...coords && { coords },
944
- ...userSubmitted && { submittedAtTimestamp: timestamp },
945
- ...isDisapproved && { failedAtTimestamp: timestamp }
946
- };
947
- try {
948
- const updateResult = await tables.puzzlecaptcha.updateOne(
949
- { challenge },
950
- { $set: setStage }
951
- );
952
- if (updateResult.matchedCount === 0) {
953
- const err = new common.ProsopoDBError("DATABASE.CAPTCHA_GET_FAILED", {
954
- context: {
955
- challenge,
956
- ...setStage
957
- },
958
- logger: this.logger
959
- });
960
- this.logger.info(() => ({
961
- err,
962
- msg: "No PuzzleCaptcha record found to update"
963
- }));
964
- throw err;
965
- }
966
- this.logger.info(() => ({
967
- data: {
968
- challenge,
969
- ...setStage
970
- },
971
- msg: "PuzzleCaptcha record updated successfully"
972
- }));
973
- this.centralStreamer?.streamPuzzleUpdate(
974
- () => this.getPuzzleCaptchaRecordByChallenge(challenge),
975
- (ts) => this.tables.puzzlecaptcha.updateOne(
976
- { challenge, lastUpdatedTimestamp: { $lte: ts } },
977
- {
978
- $set: { storedAtTimestamp: ts },
979
- $unset: { pendingStage: 1 }
980
- }
981
- ).then(() => {
982
- })
983
- );
984
- } catch (error) {
985
- const err = new common.ProsopoDBError("DATABASE.CAPTCHA_UPDATE_FAILED", {
986
- context: {
987
- error,
988
- challenge,
989
- ...setStage
990
- },
991
- logger: this.logger
992
- });
993
- this.logger.error(() => ({
994
- err,
995
- msg: "Failed to update PuzzleCaptcha record"
996
- }));
997
- throw err;
998
- }
999
- }
1000
- async updatePuzzleCaptchaRecord(challenge, updates) {
1001
- const tables = this.getTables();
1002
- const timestamp = /* @__PURE__ */ new Date();
1003
- const baseSet = {
1004
- ...updates,
1005
- pendingStage: true,
1006
- ...updates.serverChecked === true && {
1007
- verifiedAtTimestamp: timestamp
1008
- },
1009
- ...updates.userSubmitted === true && {
1010
- submittedAtTimestamp: timestamp
1011
- },
1012
- ...updates.result?.status === types.CaptchaStatus.disapproved && {
1013
- failedAtTimestamp: timestamp
1014
- }
1015
- };
1016
- await tables.puzzlecaptcha.updateOne({ challenge }, { $set: baseSet });
1017
- this.centralStreamer?.streamPuzzleUpdate(
1018
- () => this.getPuzzleCaptchaRecordByChallenge(challenge),
1019
- (ts) => this.tables.puzzlecaptcha.updateOne(
1020
- { challenge, lastUpdatedTimestamp: { $lte: ts } },
1021
- {
1022
- $set: { storedAtTimestamp: ts },
1023
- $unset: { pendingStage: 1 }
1024
- }
1025
- ).then(() => {
1026
- })
1027
- );
1028
- }
1029
- /** @description Get serverChecked Dapp User image captcha commitments from the commitments table
1030
- */
1031
- async getCheckedDappUserCommitments() {
1032
- const filter = { [types.StoredStatusNames.serverChecked]: true };
1033
- const docs = await this.tables?.commitment.find(filter).lean();
1034
- return docs || [];
1035
- }
1036
- /** @description Get Dapp User captcha commitments from the commitments table that have not been counted towards the
1037
- * client's total.
1038
- *
1039
- * Served by the `pendingStage_partial` index. Records have
1040
- * `pendingStage: true` set on insert and on every mutation (see
1041
- * `updateDappUserCommitment`, `markDappUserCommitmentsChecked`,
1042
- * `approveDappUserCommitment`, `disapproveDappUserCommitment`,
1043
- * `storePendingImageCommitment`). `markDappUserCommitmentsStored` clears
1044
- * the flag after a successful stage, guarded by `lastUpdatedTimestamp`
1045
- * so an in-flight update isn't lost.
1046
- */
1047
- async getUnstoredDappUserCommitments(limit = 1e3, skip = 0) {
1048
- const docs = await this.tables?.commitment.find({ pendingStage: true }).sort({ _id: 1 }).skip(skip).limit(limit).lean();
1049
- return docs || [];
1050
- }
1051
- /** @description Mark a list of captcha commits as stored.
1052
- *
1053
- * `asOfTimestamp` defaults to "now" but the sweep should pass the time
1054
- * at which it fetched the batch. The lastUpdatedTimestamp guard prevents
1055
- * us from clearing pendingStage on a record that was mutated between
1056
- * fetch and mark-stored — those records will leave pendingStage set so
1057
- * the next sweep picks them up.
1058
- */
1059
- async markDappUserCommitmentsStored(commitmentIds, asOfTimestamp = /* @__PURE__ */ new Date()) {
1060
- await this.tables?.commitment.updateMany(
1061
- {
1062
- id: { $in: commitmentIds },
1063
- lastUpdatedTimestamp: { $lte: asOfTimestamp }
1064
- },
1065
- {
1066
- $set: { storedAtTimestamp: /* @__PURE__ */ new Date() },
1067
- $unset: { pendingStage: 1 }
1068
- },
1069
- { upsert: false }
1070
- );
1071
- }
1072
- /** @description Mark a list of captcha commits as checked
1073
- */
1074
- async markDappUserCommitmentsChecked(commitmentIds) {
1075
- const timestamp = /* @__PURE__ */ new Date();
1076
- await this.tables?.commitment.updateMany({ id: { $in: commitmentIds } }, [
1077
- {
1078
- $set: {
1079
- serverChecked: true,
1080
- lastUpdatedTimestamp: timestamp,
1081
- pendingStage: true,
1082
- verifiedAtTimestamp: {
1083
- $ifNull: ["$verifiedAtTimestamp", timestamp]
1084
- }
1085
- }
1086
- }
1087
- ]);
1088
- }
1089
- /** @description Update an image captcha commitment
1090
- */
1091
- async updateDappUserCommitment(commitmentId, updates) {
1092
- const filter = { id: commitmentId };
1093
- const timestamp = /* @__PURE__ */ new Date();
1094
- const baseSet = {
1095
- ...updates,
1096
- lastUpdatedAtTimestamp: timestamp,
1097
- pendingStage: true
1098
- };
1099
- const pipelineExprs = {};
1100
- if (updates.userSubmitted === true) {
1101
- pipelineExprs.submittedAtTimestamp = {
1102
- $ifNull: ["$submittedAtTimestamp", timestamp]
1103
- };
1104
- }
1105
- if (updates.serverChecked === true) {
1106
- pipelineExprs.verifiedAtTimestamp = {
1107
- $ifNull: ["$verifiedAtTimestamp", timestamp]
1108
- };
1109
- }
1110
- if (updates.result?.status === types.CaptchaStatus.disapproved) {
1111
- pipelineExprs.failedAtTimestamp = {
1112
- $ifNull: ["$failedAtTimestamp", timestamp]
1113
- };
1114
- }
1115
- if (Object.keys(pipelineExprs).length === 0) {
1116
- await this.tables?.commitment.updateOne(filter, { $set: baseSet });
1117
- return;
1118
- }
1119
- await this.tables?.commitment.updateOne(filter, [
1120
- { $set: { ...baseSet, ...pipelineExprs } }
1121
- ]);
1122
- }
1123
- /**
1124
- * @description Get Dapp User PoW captcha commitments that have not been counted towards the client's total
1125
- * @param {number} limit Maximum number of records to return
1126
- * @param {number} skip Number of records to skip (for pagination)
1127
- * @returns {Promise<PoWCaptchaRecord[]>} Array of PoW captcha records
1128
- */
1129
- async getUnstoredDappUserPoWCommitments(limit = 1e3, skip = 0) {
1130
- const docs = await this.tables?.powcaptcha.find({ pendingStage: true }).sort({ _id: 1 }).skip(skip).limit(limit).lean();
1131
- return docs || [];
1132
- }
1133
- /** @description Mark a list of PoW captcha commits as stored.
1134
- *
1135
- * `asOfTimestamp` defaults to "now" but the sweep should pass the time
1136
- * at which it fetched the batch. See markDappUserCommitmentsStored for
1137
- * the guard rationale.
1138
- */
1139
- async markDappUserPoWCommitmentsStored(challenges, asOfTimestamp = /* @__PURE__ */ new Date()) {
1140
- await this.tables?.powcaptcha.updateMany(
1141
- {
1142
- challenge: { $in: challenges },
1143
- lastUpdatedTimestamp: { $lte: asOfTimestamp }
1144
- },
1145
- {
1146
- $set: { storedAtTimestamp: /* @__PURE__ */ new Date() },
1147
- $unset: { pendingStage: 1 }
1148
- },
1149
- { upsert: false }
1150
- );
1151
- }
1152
- /** @description Mark a list of PoW captcha commits as checked by the server
1153
- */
1154
- async markDappUserPoWCommitmentsChecked(challenges) {
1155
- const timestamp = /* @__PURE__ */ new Date();
1156
- await this.tables?.powcaptcha.updateMany(
1157
- { challenge: { $in: challenges } },
1158
- [
1159
- {
1160
- $set: {
1161
- serverChecked: true,
1162
- lastUpdatedTimestamp: timestamp,
1163
- pendingStage: true,
1164
- verifiedAtTimestamp: {
1165
- $ifNull: ["$verifiedAtTimestamp", timestamp]
1166
- }
1167
- }
1168
- }
1169
- ],
1170
- { upsert: false }
1171
- );
1172
- }
1173
- /**
1174
- * Store a new session record
1175
- */
1176
- async storeSessionRecord(sessionRecord) {
1177
- try {
1178
- this.logger.debug(() => ({
1179
- data: { action: "storing", sessionRecord }
1180
- }));
1181
- const recordWithFlag = {
1182
- ...sessionRecord,
1183
- lastUpdatedTimestamp: sessionRecord.lastUpdatedTimestamp ?? sessionRecord.createdAt,
1184
- pendingStage: true
1185
- };
1186
- await this.tables.session.create(recordWithFlag);
1187
- this.centralStreamer?.streamSessionRecord(
1188
- recordWithFlag,
1189
- (ts) => this.tables.session.updateOne(
1190
- {
1191
- sessionId: sessionRecord.sessionId,
1192
- lastUpdatedTimestamp: { $lte: ts }
1193
- },
1194
- {
1195
- $set: { storedAtTimestamp: ts },
1196
- $unset: { pendingStage: 1 }
1197
- }
1198
- ).then(() => {
1199
- })
1200
- );
1201
- } catch (err) {
1202
- throw new common.ProsopoDBError("DATABASE.SESSION_STORE_FAILED", {
1203
- context: { error: err, sessionId: sessionRecord.sessionId },
1204
- logger: this.logger
1205
- });
1206
- }
1207
- }
1208
- /**
1209
- * Persist a synthetic Session record for a request that was 401'd at the
1210
- * request-time block middleware. The record is written with
1211
- * `blocked=true` + `deleted=true` so the captcha flow can't pick it up
1212
- * downstream, and with sentinel values for the schema-required fields
1213
- * the blocked request never had a chance to populate (score / threshold /
1214
- * captchaType / etc.).
1215
- *
1216
- * Designed for fire-and-forget: callers `void`-cast the promise and any
1217
- * Mongo error is swallowed-and-logged so the 401 response is never
1218
- * delayed by a persistence failure. The structured log line at the call
1219
- * site is the source of truth either way.
1220
- *
1221
- * Mirrors `storeSessionRecord` enough that the Traffic-page
1222
- * aggregations can read these records out of the central-streamer
1223
- * pipeline once the UI work lands.
1224
- */
1225
- async storeBlockedSession(record) {
1226
- try {
1227
- const stored = {
1228
- ...record,
1229
- blocked: true,
1230
- deleted: true,
1231
- // Stamp storedAtTimestamp so the existing TTL index on the
1232
- // sessions collection sweeps the record after one day.
1233
- storedAtTimestamp: record.storedAtTimestamp ?? record.createdAt,
1234
- lastUpdatedTimestamp: record.lastUpdatedTimestamp ?? record.createdAt
1235
- };
1236
- await this.tables.session.create(stored);
1237
- this.centralStreamer?.streamSessionRecord(
1238
- stored,
1239
- (ts) => this.tables.session.updateOne(
1240
- {
1241
- sessionId: stored.sessionId,
1242
- lastUpdatedTimestamp: { $lte: ts }
1243
- },
1244
- { $set: { storedAtTimestamp: ts } }
1245
- ).then(() => {
1246
- })
1247
- );
1248
- } catch (err) {
1249
- this.logger.warn(() => ({
1250
- err,
1251
- msg: "Failed to persist blocked session record",
1252
- data: { sessionId: record.sessionId, siteKey: record.siteKey }
1253
- }));
1254
- }
1255
- }
1256
- /**
1257
- * Get a session record by sessionId
1258
- */
1259
- async getSessionRecordBySessionId(sessionId) {
1260
- const filter = { sessionId };
1261
- const doc = await this.tables.session.findOne(filter, {
1262
- sessionId: 1,
1263
- token: 1,
1264
- score: 1,
1265
- threshold: 1,
1266
- scoreComponents: 1,
1267
- ipAddress: 1,
1268
- ipInfo: 1,
1269
- webView: 1,
1270
- iFrame: 1,
1271
- isEscalation: 1,
1272
- decryptedHeadHash: 1,
1273
- siteKey: 1,
1274
- reason: 1,
1275
- mode: 1,
1276
- solvedImagesCount: 1,
1277
- userSitekeyIpHash: 1,
1278
- simdReadings: 1,
1279
- dnsEvent: 1,
1280
- currentUrl: 1,
1281
- iframeUrl: 1,
1282
- // captchaType is required by the peek-before-consume path
1283
- // in `CaptchaManager.isValidRequest` — without it, every
1284
- // escalation peek would compare `undefined !== <requested>`
1285
- // and forcibly return INCORRECT_CAPTCHA_TYPE on the happy
1286
- // path too. Keep this projection in sync with whatever
1287
- // fields the read-only callers need.
1288
- captchaType: 1,
1289
- "headers.user-agent": 1,
1290
- "headers.accept": 1,
1291
- "headers.accept-language": 1,
1292
- "headers.accept-encoding": 1,
1293
- "headers.sec-ch-ua": 1,
1294
- "headers.sec-ch-ua-mobile": 1,
1295
- "headers.sec-ch-ua-platform": 1,
1296
- "headers.sec-ch-ua-platform-version": 1,
1297
- "headers.sec-fetch-dest": 1,
1298
- "headers.sec-fetch-mode": 1,
1299
- "headers.sec-fetch-site": 1,
1300
- "headers.sec-fetch-user": 1,
1301
- "headers.referer": 1,
1302
- "headers.origin": 1,
1303
- "headers.prosopo-user": 1,
1304
- "headers.prosopo-site-key": 1,
1305
- "headers.prosopo-type": 1,
1306
- "headers.x-tls-version": 1
1307
- }).lean();
1308
- return doc || void 0;
1309
- }
1310
- /**
1311
- * Get a session record by token
1312
- * Used to ensure that a token is not used more than once.
1313
- */
1314
- async getSessionRecordByToken(token) {
1315
- const filter = { token };
1316
- const record = await this.tables.session.findOne(filter).lean();
1317
- return record || void 0;
1318
- }
1319
- /**
1320
- * Check if a session exists and mark it as removed
1321
- * @returns The session record if it existed, undefined otherwise
1322
- */
1323
- async checkAndRemoveSession(sessionId) {
1324
- this.logger.debug(() => ({
1325
- data: { action: "checking and removing", sessionId }
1326
- }));
1327
- const filter = {
1328
- sessionId,
1329
- deleted: { $exists: false }
1330
- };
1331
- try {
1332
- const session = await this.tables.session.findOneAndUpdate(filter, {
1333
- deleted: true,
1334
- lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
1335
- pendingStage: true
1336
- }).lean();
1337
- return session || void 0;
1338
- } catch (err) {
1339
- throw new common.ProsopoDBError("DATABASE.SESSION_CHECK_REMOVE_FAILED", {
1340
- context: { error: err, sessionId },
1341
- logger: this.logger
1342
- });
1343
- }
1344
- }
1345
- /**
1346
- * Update a session record by sessionId. Pure Mongo write — callers in
1347
- * the provider package are responsible for refreshing the Redis cache
1348
- * via `RedisWriteQueue.patchCachedSession`, matching the existing
1349
- * caller-side caching pattern (see `frictionlessTasks.createSession`).
1350
- */
1351
- async updateSessionRecord(sessionId, updates, streamToCentral) {
1352
- try {
1353
- await this.tables.session.updateOne(
1354
- { sessionId },
1355
- {
1356
- $set: {
1357
- ...updates,
1358
- lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
1359
- pendingStage: true
1360
- }
1361
- }
1362
- );
1363
- if (streamToCentral && this.centralStreamer) {
1364
- const streamer = this.centralStreamer;
1365
- const markStored = (ts) => this.tables.session.updateOne(
1366
- { sessionId, lastUpdatedTimestamp: { $lte: ts } },
1367
- {
1368
- $set: { storedAtTimestamp: ts },
1369
- $unset: { pendingStage: 1 }
1370
- }
1371
- ).then(() => {
1372
- });
1373
- this.tables.session.findOne({ sessionId }).lean().then((record) => {
1374
- if (record) {
1375
- streamer.streamSessionRecord(record, markStored);
1376
- }
1377
- }).catch(() => {
1378
- });
1379
- }
1380
- } catch (err) {
1381
- throw new common.ProsopoDBError("DATABASE.SESSION_GET_FAILED", {
1382
- context: { error: err, sessionId },
1383
- logger: this.logger
1384
- });
1385
- }
1386
- }
1387
- /**
1388
- * Atomically record SIMD CPU fingerprint readings on the session — only
1389
- * if they aren't already present. Uses a Mongo aggregation-pipeline
1390
- * update so `simdReadings` and `simdReadingsStage` are set together,
1391
- * first-hop-wins. Pure Mongo write — cache refresh is the caller's
1392
- * responsibility via `RedisWriteQueue.patchCachedSimdReadingsIfAbsent`.
1393
- */
1394
- async recordSessionSimdReadingsIfAbsent(sessionId, readings, stage) {
1395
- try {
1396
- await this.tables.session.updateOne({ sessionId }, [
1397
- {
1398
- $set: {
1399
- simdReadings: { $ifNull: ["$simdReadings", readings] },
1400
- simdReadingsStage: { $ifNull: ["$simdReadingsStage", stage] },
1401
- lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
1402
- pendingStage: true
1403
- }
1404
- }
1405
- ]);
1406
- } catch (err) {
1407
- throw new common.ProsopoDBError("DATABASE.SESSION_GET_FAILED", {
1408
- context: { error: err, sessionId, stage },
1409
- logger: this.logger
1410
- });
1411
- }
1412
- }
1413
- /**
1414
- * Merge a DNS sidecar observation into the session's `dnsEvent` subdoc
1415
- * using dotted-path `$set` so DNS-leg and HTTP-leg events on the same
1416
- * session don't clobber each other. Replaces the prior read-modify-write
1417
- * pattern that lost half the fields under any race (and 100% of the time
1418
- * when the read projection didn't include `dnsEvent`).
1419
- *
1420
- * `receivedAtIfAbsent` is written only on the first hit for a session
1421
- * via a `$cond` in an aggregation-pipeline update; later events leave
1422
- * the original timestamp alone.
1423
- */
1424
- async mergeSessionDnsEvent(sessionId, fields, receivedAtIfAbsent) {
1425
- const setStage = {
1426
- "dnsEvent.receivedAt": {
1427
- $ifNull: ["$dnsEvent.receivedAt", receivedAtIfAbsent]
1428
- },
1429
- lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
1430
- pendingStage: true
1431
- };
1432
- if (fields.resolverIp !== void 0) {
1433
- setStage["dnsEvent.resolverIp"] = fields.resolverIp;
1434
- }
1435
- if (fields.peerIp !== void 0) {
1436
- setStage["dnsEvent.peerIp"] = fields.peerIp;
1437
- }
1438
- if (fields.pathValid !== void 0) {
1439
- setStage["dnsEvent.pathValid"] = fields.pathValid;
1440
- }
1441
- try {
1442
- const result = await this.tables.session.updateOne({ sessionId }, [
1443
- { $set: setStage }
1444
- ]);
1445
- return result.matchedCount > 0;
1446
- } catch (err) {
1447
- throw new common.ProsopoDBError("DATABASE.SESSION_GET_FAILED", {
1448
- context: { error: err, sessionId },
1449
- logger: this.logger
1450
- });
1451
- }
1452
- }
1453
- /**
1454
- * Get an active session by user IP hash
1455
- * @param userSitekeyIpHash The hash of user, IP and sitekey combination
1456
- * @returns The session record if it exists and is not deleted, undefined otherwise
1457
- */
1458
- async getSessionByuserSitekeyIpHash(userSitekeyIpHash) {
1459
- this.logger.debug(() => ({
1460
- data: { action: "getting session by user IP hash", userSitekeyIpHash }
1461
- }));
1462
- const filter = {
1463
- userSitekeyIpHash,
1464
- deleted: { $exists: false }
1465
- };
1466
- try {
1467
- const session = await this.tables.session.findOne(filter).lean();
1468
- return session || void 0;
1469
- } catch (err) {
1470
- throw new common.ProsopoDBError("DATABASE.SESSION_GET_FAILED", {
1471
- context: { error: err, userSitekeyIpHash },
1472
- logger: this.logger
1473
- });
1474
- }
1475
- }
1476
- /** Get unstored session records
1477
- * @description Get session records that have not been stored yet.
1478
- *
1479
- * Served by the `pendingStage_partial` index — see
1480
- * `getUnstoredDappUserCommitments` for the lifecycle of the flag.
1481
- * `checkAndRemoveSession` also flips the flag so consumed sessions
1482
- * propagate to the central DB via the next sweep.
1483
- * @param limit
1484
- * @param skip
1485
- */
1486
- getUnstoredSessionRecords(limit = 1e3, skip = 0) {
1487
- return Promise.resolve(this.tables?.session).then(
1488
- (tbl) => tbl?.find({ pendingStage: true }).sort({ _id: 1 }).skip(skip).limit(limit).lean()
1489
- ).then((docs) => docs || []);
1490
- }
1491
- /** Mark a list of session records as stored.
1492
- *
1493
- * `asOfTimestamp` defaults to "now" but the sweep should pass the time
1494
- * at which it fetched the batch. See markDappUserCommitmentsStored for
1495
- * the guard rationale.
1496
- */
1497
- async markSessionRecordsStored(sessionIds, asOfTimestamp = /* @__PURE__ */ new Date()) {
1498
- await this.tables?.session.updateMany(
1499
- {
1500
- sessionId: { $in: sessionIds },
1501
- lastUpdatedTimestamp: { $lte: asOfTimestamp }
1502
- },
1503
- {
1504
- $set: { storedAtTimestamp: /* @__PURE__ */ new Date() },
1505
- $unset: { pendingStage: 1 }
1506
- },
1507
- { upsert: false }
1508
- );
1509
- }
1510
- /**
1511
- * @description Store a Dapp User's pending record
1512
- */
1513
- async storePendingImageCommitment(userAccount, requestHash, salt, deadlineTimestamp, requestedAtTimestamp, ipAddress, threshold, sessionId, ipInfo) {
1514
- if (!is.isHex(requestHash)) {
1515
- throw new common.ProsopoDBError("DATABASE.INVALID_HASH", {
1516
- context: {
1517
- failedFuncName: this.storePendingImageCommitment.name,
1518
- requestHash
1519
- }
1520
- });
1521
- }
1522
- const pendingRecord = {
1523
- userAccount,
1524
- pending: true,
1525
- salt,
1526
- requestHash,
1527
- deadlineTimestamp,
1528
- requestedAtTimestamp,
1529
- ipAddress,
1530
- sessionId,
1531
- threshold,
1532
- ipInfo,
1533
- // Deliberately NOT setting pendingStage here. Placeholder
1534
- // records have id: "" until the user submits a solution; if we
1535
- // flag them, the sweep picks them up via the partial index but
1536
- // then `markDappUserCommitmentsStored` runs
1537
- // `{ id: { $in: ["", ...] } }` which collapses to a single ""
1538
- // bound and scans every empty-id row via the `id_-1` index —
1539
- // turning a cheap sweep into a fresh cache evictor. The real
1540
- // commitment only gets `pendingStage: true` once `id` is
1541
- // populated by approve/disapprove, at which point staging it is
1542
- // meaningful.
1543
- // Placeholder fields required by schema but not needed for pending state
1544
- dappAccount: "",
1545
- providerAccount: "",
1546
- datasetId: "",
1547
- id: "",
1548
- // id is populated by the user's solution record when the user submits a solution, so we can leave it blank here
1549
- result: { status: types.CaptchaStatus.pending },
1550
- headers: {},
1551
- ja4: "",
1552
- userSignature: "",
1553
- userSubmitted: false,
1554
- serverChecked: false
1555
- };
1556
- await this.tables?.commitment.updateOne(
1557
- { requestHash },
1558
- { $set: pendingRecord },
1559
- { upsert: true }
1560
- );
1561
- }
1562
- /**
1563
- * @description Get a user's pending record
1564
- */
1565
- async getPendingImageCommitment(requestHash) {
1566
- if (!is.isHex(requestHash)) {
1567
- throw new common.ProsopoDBError("DATABASE.INVALID_HASH", {
1568
- context: {
1569
- failedFuncName: this.getPendingImageCommitment.name,
1570
- requestHash
1571
- }
1572
- });
1573
- }
1574
- const doc = await this.tables?.commitment.findOne({
1575
- requestHash,
1576
- pending: true
1577
- }).lean();
1578
- if (doc) {
1579
- return {
1580
- dappAccount: doc.dappAccount,
1581
- pending: doc.pending,
1582
- salt: doc.salt,
1583
- requestHash: doc.requestHash,
1584
- deadlineTimestamp: doc.deadlineTimestamp,
1585
- requestedAtTimestamp: doc.requestedAtTimestamp,
1586
- ipAddress: doc.ipAddress,
1587
- sessionId: doc.sessionId,
1588
- threshold: doc.threshold
1589
- };
1590
- }
1591
- throw new common.ProsopoDBError("DATABASE.PENDING_RECORD_NOT_FOUND", {
1592
- context: {
1593
- failedFuncName: this.getPendingImageCommitment.name,
1594
- requestHash
1595
- }
1596
- });
1597
- }
1598
- /**
1599
- * @description Mark a pending request as used
1600
- */
1601
- async updatePendingImageCommitmentStatus(requestHash) {
1602
- if (!is.isHex(requestHash)) {
1603
- throw new common.ProsopoDBError("DATABASE.INVALID_HASH", {
1604
- context: {
1605
- failedFuncName: this.updatePendingImageCommitmentStatus.name,
1606
- requestHash
1607
- }
1608
- });
1609
- }
1610
- await this.tables?.commitment.updateOne(
1611
- { requestHash },
1612
- {
1613
- $set: {
1614
- pending: false
1615
- }
1616
- }
1617
- );
1618
- }
1619
- /**
1620
- * @description Get all unsolved captchas
1621
- */
1622
- async getAllCaptchasByDatasetId(datasetId, state) {
1623
- const filter = {
1624
- datasetId,
1625
- solved: state === types.CaptchaStates.Solved
1626
- };
1627
- const cursor = this.tables?.captcha.find(filter).lean();
1628
- const docs = await cursor;
1629
- if (docs) {
1630
- return docs.map(({ _id, ...keepAttrs }) => keepAttrs);
1631
- }
1632
- throw new common.ProsopoDBError("DATABASE.CAPTCHA_GET_FAILED");
1633
- }
1634
- /**
1635
- * @description Get all dapp user solutions by captchaIds
1636
- */
1637
- async getAllDappUserSolutions(captchaId) {
1638
- const filter = {
1639
- captchaId: { $in: captchaId }
1640
- };
1641
- const cursor = this.tables?.usersolution?.find(filter).lean();
1642
- const docs = await cursor;
1643
- if (docs) {
1644
- return docs.map(
1645
- ({ _id, ...keepAttrs }) => keepAttrs
1646
- );
1647
- }
1648
- throw new common.ProsopoDBError("DATABASE.SOLUTION_GET_FAILED");
1649
- }
1650
- async getDatasetIdWithSolvedCaptchasOfSizeN(solvedCaptchaCount) {
1651
- const cursor = this.tables?.solution.aggregate([
1652
- {
1653
- $match: {}
1654
- },
1655
- {
1656
- $group: {
1657
- _id: "$datasetId",
1658
- count: { $sum: 1 }
1659
- }
1660
- },
1661
- {
1662
- $match: {
1663
- count: { $gte: solvedCaptchaCount }
1664
- }
1665
- },
1666
- {
1667
- $sample: { size: 1 }
1668
- }
1669
- ]);
1670
- const docs = await cursor;
1671
- if (docs?.length) {
1672
- return docs[0]._id;
1673
- }
1674
- throw new common.ProsopoDBError("DATABASE.DATASET_WITH_SOLUTIONS_GET_FAILED");
1675
- }
1676
- async getRandomSolvedCaptchasFromSingleDataset(datasetId, size) {
1677
- if (!is.isHex(datasetId)) {
1678
- throw new common.ProsopoDBError("DATABASE.INVALID_HASH", {
1679
- context: {
1680
- failedFuncName: this.getRandomSolvedCaptchasFromSingleDataset.name,
1681
- datasetId
1682
- }
1683
- });
1684
- }
1685
- const sampleSize = size ? Math.abs(Math.trunc(size)) : 1;
1686
- const cursor = this.tables?.solution.aggregate([
1687
- { $match: { datasetId } },
1688
- { $sample: { size: sampleSize } },
1689
- {
1690
- $project: {
1691
- captchaId: 1,
1692
- captchaContentId: 1,
1693
- solution: 1
1694
- }
1695
- }
1696
- ]);
1697
- const docs = await cursor;
1698
- if (docs?.length) {
1699
- return docs;
1700
- }
1701
- throw new common.ProsopoDBError("DATABASE.SOLUTION_GET_FAILED", {
1702
- context: {
1703
- failedFuncName: this.getRandomSolvedCaptchasFromSingleDataset.name,
1704
- datasetId,
1705
- size
1706
- }
1707
- });
1708
- }
1709
- /**
1710
- * @description Get dapp user solution by ID
1711
- * @param {string[]} commitmentId
1712
- */
1713
- async getDappUserSolutionById(commitmentId) {
1714
- const filter = {
1715
- commitmentId
1716
- };
1717
- const project = { _id: 0 };
1718
- const cursor = this.tables?.usersolution?.findOne(filter, project).lean();
1719
- const doc = await cursor;
1720
- if (doc) {
1721
- return doc;
1722
- }
1723
- throw new common.ProsopoDBError("DATABASE.SOLUTION_GET_FAILED", {
1724
- context: { failedFuncName: this.getCaptchaById.name, commitmentId }
1725
- });
1726
- }
1727
- /**
1728
- * @description Get dapp user commitment by user account
1729
- * @param commitmentId
1730
- */
1731
- async getDappUserCommitmentById(commitmentId) {
1732
- const filter = { id: commitmentId };
1733
- const commitmentCursor = this.tables?.commitment?.findOne(filter, {
1734
- id: 1,
1735
- result: 1,
1736
- serverChecked: 1,
1737
- requestedAtTimestamp: 1,
1738
- submittedAtTimestamp: 1,
1739
- verifiedAtTimestamp: 1,
1740
- failedAtTimestamp: 1,
1741
- ipAddress: 1,
1742
- sessionId: 1,
1743
- userAccount: 1,
1744
- dappAccount: 1,
1745
- headers: 1,
1746
- ipInfo: 1
1747
- }).lean();
1748
- const doc = await commitmentCursor;
1749
- return doc ? doc : void 0;
1750
- }
1751
- /**
1752
- * @description Get dapp user commitment by user account
1753
- * @param {string} userAccount
1754
- * @param {string} dappAccount
1755
- */
1756
- async getDappUserCommitmentByAccount(userAccount, dappAccount) {
1757
- const filter = {
1758
- userAccount,
1759
- dappAccount
1760
- };
1761
- const project = {
1762
- _id: 0,
1763
- result: 1
1764
- };
1765
- const sort = { sort: { _id: -1 } };
1766
- const docs = await this.tables?.commitment?.find(filter, project, sort).lean();
1767
- return docs ? docs : [];
1768
- }
1769
- /**
1770
- * @description Approve a dapp user's solution
1771
- * @param {string[]} commitmentId
1772
- * @param coords
1773
- */
1774
- async approveDappUserCommitment(commitmentId, coords) {
1775
- try {
1776
- const result = { status: types.CaptchaStatus.approved };
1777
- const updateDoc = {
1778
- result,
1779
- lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
1780
- pendingStage: true,
1781
- ...coords ? { coords } : {}
1782
- };
1783
- const filter = { id: commitmentId };
1784
- await this.tables?.commitment?.findOneAndUpdate(filter, { $set: updateDoc }, { upsert: false }).lean();
1785
- this.centralStreamer?.streamImageUpdate(
1786
- () => this.tables.commitment.findOne({ id: commitmentId }).lean(),
1787
- (ts) => this.tables.commitment.updateOne(
1788
- { id: commitmentId, lastUpdatedTimestamp: { $lte: ts } },
1789
- {
1790
- $set: { storedAtTimestamp: ts },
1791
- $unset: { pendingStage: 1 }
1792
- }
1793
- ).then(() => {
1794
- })
1795
- );
1796
- } catch (err) {
1797
- throw new common.ProsopoDBError("DATABASE.SOLUTION_APPROVE_FAILED", {
1798
- context: { error: err, commitmentId }
1799
- });
1800
- }
1801
- }
1802
- /**
1803
- * @description Disapprove a dapp user's solution
1804
- * @param {string} commitmentId
1805
- * @param coords
1806
- * @param reason
1807
- */
1808
- async disapproveDappUserCommitment(commitmentId, reason, coords) {
1809
- try {
1810
- const updateDoc = {
1811
- result: { status: types.CaptchaStatus.disapproved, reason },
1812
- lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
1813
- pendingStage: true,
1814
- ...coords ? { coords } : {}
1815
- };
1816
- const filter = { id: commitmentId };
1817
- await this.tables?.commitment?.findOneAndUpdate(filter, { $set: updateDoc }, { upsert: false }).lean();
1818
- this.centralStreamer?.streamImageUpdate(
1819
- () => this.tables.commitment.findOne({ id: commitmentId }).lean(),
1820
- (ts) => this.tables.commitment.updateOne(
1821
- { id: commitmentId, lastUpdatedTimestamp: { $lte: ts } },
1822
- {
1823
- $set: { storedAtTimestamp: ts },
1824
- $unset: { pendingStage: 1 }
1825
- }
1826
- ).then(() => {
1827
- })
1828
- );
1829
- } catch (err) {
1830
- throw new common.ProsopoDBError("DATABASE.SOLUTION_APPROVE_FAILED", {
1831
- context: { error: err, commitmentId }
1832
- });
1833
- }
1834
- }
1835
- /**
1836
- * @description Flag a dapp user's solutions as used by calculated solution
1837
- * @param {string[]} captchaIds
1838
- */
1839
- async flagProcessedDappUserSolutions(captchaIds) {
1840
- try {
1841
- await this.tables?.usersolution?.updateMany(
1842
- { captchaId: { $in: captchaIds } },
1843
- { $set: { processed: true } },
1844
- { upsert: false }
1845
- ).lean();
1846
- } catch (err) {
1847
- throw new common.ProsopoDBError("DATABASE.SOLUTION_FLAG_FAILED", {
1848
- context: { error: err, captchaIds }
1849
- });
1850
- }
1851
- }
1852
- /**
1853
- * @description Flag dapp users' commitments as used by calculated solution
1854
- * @param {string[]} commitmentIds
1855
- */
1856
- async flagProcessedDappUserCommitments(commitmentIds) {
1857
- try {
1858
- const distinctCommitmentIds = [...new Set(commitmentIds)];
1859
- await this.tables?.commitment?.updateMany(
1860
- { id: { $in: distinctCommitmentIds } },
1861
- { $set: { processed: true } },
1862
- { upsert: false }
1863
- ).lean();
1864
- } catch (err) {
1865
- throw new common.ProsopoDBError("DATABASE.COMMITMENT_FLAG_FAILED", {
1866
- context: { error: err, commitmentIds }
1867
- });
1868
- }
1869
- }
1870
- /**
1871
- * @description Get a scheduled task status record by task ID and status
1872
- */
1873
- async getScheduledTaskStatus(taskId, status) {
1874
- const filter = {
1875
- _id: taskId,
1876
- status
1877
- };
1878
- const cursor = await this.tables?.scheduler?.findOne(filter).lean();
1879
- return cursor ? cursor : void 0;
1880
- }
1881
- /**
1882
- * @description Get the most recent scheduled task status record for a given task
1883
- */
1884
- async getLastScheduledTaskStatus(task, status) {
1885
- const filter = { processName: task };
1886
- if (status) {
1887
- filter.status = status;
1888
- }
1889
- const sort = {
1890
- datetime: -1
1891
- };
1892
- const cursor = await this.tables?.scheduler?.findOne(filter).sort(sort).limit(1).lean();
1893
- return cursor ? cursor : void 0;
1894
- }
1895
- /**
1896
- * @description Create the status of a scheduled task
1897
- */
1898
- async createScheduledTaskStatus(taskName, status) {
1899
- const now = /* @__PURE__ */ new Date();
1900
- const doc = typesDatabase.ScheduledTaskSchema.parse({
1901
- processName: taskName,
1902
- datetime: now,
1903
- status
1904
- });
1905
- const taskRecord = await this.tables?.scheduler.create(doc);
1906
- return taskRecord._id;
1907
- }
1908
- /**
1909
- * @description Update the status of a scheduled task and an optional result
1910
- */
1911
- async updateScheduledTaskStatus(taskId, status, result) {
1912
- const update = {
1913
- status,
1914
- updated: /* @__PURE__ */ new Date(),
1915
- ...result && { result }
1916
- };
1917
- const filter = { _id: taskId };
1918
- await this.tables?.scheduler.updateOne(
1919
- filter,
1920
- { $set: update },
1921
- {
1922
- upsert: false
1923
- }
1924
- );
1925
- }
1926
- /**
1927
- * @description Clean up the scheduled task status records
1928
- */
1929
- async cleanupScheduledTaskStatus(status) {
1930
- const filter = {
1931
- status
1932
- };
1933
- await this.tables?.scheduler.deleteMany(filter);
1934
- }
1935
- /**
1936
- * @description Update the client records
1937
- */
1938
- async updateClientRecords(clientRecords) {
1939
- const ops = clientRecords.map((record) => {
1940
- const clientRecord = {
1941
- account: record.account,
1942
- settings: record.settings,
1943
- tier: record.tier
1944
- };
1945
- const filter = {
1946
- account: record.account
1947
- };
1948
- return {
1949
- updateOne: {
1950
- filter,
1951
- update: {
1952
- $set: clientRecord
1953
- },
1954
- upsert: true
1955
- }
1956
- };
1957
- });
1958
- if (!ops || ops.length === 0) {
1959
- await this.logger.debug(() => ({ msg: "No client updates to apply" }));
1960
- return;
1961
- }
1962
- await this.tables?.client.bulkWrite(ops);
1963
- }
1964
- /**
1965
- * @description Remove client records by account
1966
- */
1967
- async removeClientRecords(accounts) {
1968
- if (!accounts || accounts.length === 0) {
1969
- return;
1970
- }
1971
- await this.tables?.client.deleteMany({
1972
- account: { $in: accounts }
1973
- });
1974
- }
1975
- /**
1976
- * @description Get all client records
1977
- */
1978
- async getAllClientRecords() {
1979
- const docs = await this.tables?.client.find().lean();
1980
- return docs || [];
1981
- }
1982
- /**
1983
- * @description Get a client record
1984
- */
1985
- async getClientRecord(account) {
1986
- const filter = { account };
1987
- const doc = await this.tables?.client.findOne(filter, {
1988
- account: 1,
1989
- settings: 1,
1990
- tier: 1
1991
- }).lean();
1992
- return doc ? doc : void 0;
1993
- }
1994
- /**
1995
- * @description Store a detector key
1996
- */
1997
- async storeDetectorKey(detectorKey) {
1998
- return this.tables?.detector.create({
1999
- detectorKey,
2000
- createdAt: /* @__PURE__ */ new Date()
2001
- });
2002
- }
2003
- /**
2004
- * @description Remove a detector key
2005
- * @param detectorKey The detector key to remove
2006
- * @param expirationInSeconds Optional expiration time in seconds (default is 10 minutes)
2007
- * */
2008
- async removeDetectorKey(detectorKey, expirationInSeconds) {
2009
- const filter = { detectorKey };
2010
- const expiresAt = new Date(
2011
- Date.now() + (expirationInSeconds || 10 * 60) * 1e3
2012
- );
2013
- await this.tables?.detector.updateOne(filter, {
2014
- $set: { expiresAt }
2015
- });
2016
- }
2017
- /**
2018
- * @description Get valid detector keys
2019
- */
2020
- async getDetectorKeys() {
2021
- const keyRecords = await this.tables?.detector.find(
2022
- {
2023
- $or: [{ expiresAt: { $exists: false } }, { expiresAt: null }]
2024
- },
2025
- { detectorKey: 1 }
2026
- ).sort({ createdAt: -1 }).lean();
2027
- return (keyRecords || []).map((record) => record.detectorKey);
2028
- }
2029
- /**
2030
- * Stores a decision machine artifact with a unique scope identifier.
2031
- *
2032
- * The combination of scope + dappAccount uniquely identifies a single artifact:
2033
- * - Global scope: One artifact per provider (dappAccount is null)
2034
- * - Dapp scope: One artifact per dapp account (dappAccount is specified)
2035
- *
2036
- * @param artifact - The decision machine artifact to store
2037
- */
2038
- async upsertDecisionMachineArtifact(artifact) {
2039
- const now = /* @__PURE__ */ new Date();
2040
- const dappAccount = artifact.dappAccount ?? null;
2041
- const kind = artifact.kind ?? types.DecisionMachineKind.Routing;
2042
- const filter = {
2043
- scope: artifact.scope,
2044
- dappAccount,
2045
- kind
2046
- };
2047
- await this.tables?.decisionMachine.updateOne(
2048
- filter,
2049
- {
2050
- $set: {
2051
- scope: artifact.scope,
2052
- dappAccount,
2053
- kind,
2054
- runtime: artifact.runtime,
2055
- language: artifact.language,
2056
- source: artifact.source,
2057
- name: artifact.name,
2058
- version: artifact.version,
2059
- captchaType: artifact.captchaType,
2060
- updatedAt: now
2061
- },
2062
- $setOnInsert: {
2063
- createdAt: now
2064
- }
2065
- },
2066
- { upsert: true }
2067
- );
2068
- }
2069
- /**
2070
- * Retrieves a single decision machine artifact by scope and optional dapp account.
2071
- *
2072
- * @param scope - The scope level (Global or Dapp)
2073
- * @param dappAccount - Required for Dapp scope, unused for Global scope
2074
- * @returns The matching artifact, or undefined if not found
2075
- */
2076
- async getDecisionMachineArtifact(scope, dappAccount, kind) {
2077
- const filter = {
2078
- scope,
2079
- dappAccount: dappAccount ?? null
2080
- };
2081
- if (kind !== void 0) {
2082
- filter.kind = kind;
2083
- }
2084
- const doc = await this.tables?.decisionMachine.findOne(filter).lean();
2085
- return doc ?? void 0;
2086
- }
2087
- /**
2088
- * Retrieves all decision machine artifacts.
2089
- *
2090
- * @returns Array of all decision machine artifacts
2091
- */
2092
- async getAllDecisionMachineArtifacts() {
2093
- const docs = await this.tables?.decisionMachine.find({}).lean();
2094
- return docs ?? [];
2095
- }
2096
- /**
2097
- * Retrieves a single decision machine artifact by MongoDB ObjectId.
2098
- *
2099
- * @param id - The MongoDB ObjectId as a string
2100
- * @returns The matching artifact, or undefined if not found
2101
- */
2102
- async getDecisionMachineArtifactById(id) {
2103
- const doc = await this.tables?.decisionMachine.findById(id).lean();
2104
- return doc ?? void 0;
2105
- }
2106
- /**
2107
- * Removes a decision machine artifact by MongoDB ObjectId.
2108
- *
2109
- * @param id - The MongoDB ObjectId as a string
2110
- * @returns true if deleted, false if not found
2111
- */
2112
- async removeDecisionMachineArtifact(id) {
2113
- const result = await this.tables?.decisionMachine.deleteOne({
2114
- _id: id
2115
- });
2116
- return (result?.deletedCount ?? 0) > 0;
2117
- }
2118
- /**
2119
- * Removes all decision machine artifacts.
2120
- *
2121
- * @returns The number of artifacts deleted
2122
- */
2123
- async removeAllDecisionMachineArtifacts() {
2124
- const result = await this.tables?.decisionMachine.deleteMany({});
2125
- return result?.deletedCount ?? 0;
2126
- }
2127
- /**
2128
- * @description set client context-specific entropy
2129
- */
2130
- async setClientContextEntropy(account, contextType, entropy) {
2131
- const filter = { account, contextType };
2132
- await this.tables?.clientContextEntropy.updateOne(
2133
- filter,
2134
- { $set: { account, contextType, entropy } },
2135
- { upsert: true }
2136
- );
2137
- }
2138
- /**
2139
- * @description get client context-specific entropy
2140
- */
2141
- async getClientContextEntropy(account, contextType) {
2142
- const filter = { account, contextType };
2143
- const doc = await this.tables?.clientContextEntropy.findOne(filter).lean();
2144
- return doc ? doc.entropy : void 0;
2145
- }
2146
- /** Sample captcha records from the database for a specific context */
2147
- async sampleContextEntropy(sampleSize, siteKey, contextType) {
2148
- const size = sampleSize ? Math.abs(Math.trunc(sampleSize)) : 1;
2149
- const max = 1e4;
2150
- if (size > max) {
2151
- throw new common.ProsopoDBError("DATABASE.CAPTCHA_SAMPLE_SIZE_EXCEEDED", {
2152
- context: {
2153
- failedFuncName: this.sampleContextEntropy.name,
2154
- sampleSize
2155
- }
2156
- });
2157
- }
2158
- const pipeline = [
2159
- {
2160
- $match: {
2161
- dappAccount: siteKey,
2162
- requestedAtTimestamp: {
2163
- $gt: new Date((/* @__PURE__ */ new Date()).getTime() - TWENTY_FOUR_HOURS_IN_MS)
2164
- }
2165
- }
2166
- },
2167
- { $sample: { size: max } },
2168
- {
2169
- $lookup: {
2170
- from: "sessions",
2171
- localField: "sessionId",
2172
- foreignField: "sessionId",
2173
- as: "sessionData"
2174
- }
2175
- },
2176
- {
2177
- $unwind: {
2178
- path: "$sessionData",
2179
- preserveNullAndEmptyArrays: false
2180
- }
2181
- }
2182
- ];
2183
- if (contextType === types.ContextType.Webview) {
2184
- pipeline.push({
2185
- $match: {
2186
- "sessionData.webView": true
2187
- }
2188
- });
2189
- } else if (contextType === types.ContextType.Default) {
2190
- pipeline.push({
2191
- $match: {
2192
- "sessionData.webView": false
2193
- }
2194
- });
2195
- }
2196
- pipeline.push(
2197
- { $sample: { size } },
2198
- {
2199
- $project: {
2200
- _id: 0,
2201
- sessionId: 1
2202
- }
2203
- }
2204
- );
2205
- const cursor = this.tables?.powcaptcha.aggregate(pipeline);
2206
- const docs = await cursor;
2207
- if (docs?.length === 0) {
2208
- return [];
2209
- }
2210
- return (await Promise.all(
2211
- docs.map(async (doc) => {
2212
- if (doc.sessionId) {
2213
- const tokenRecord = await this.getSessionRecordBySessionId(
2214
- doc.sessionId
2215
- );
2216
- return tokenRecord?.decryptedHeadHash;
2217
- }
2218
- return void 0;
2219
- })
2220
- )).filter((headHash) => headHash !== void 0);
2221
- }
2222
- async getSpamEmailDomain(domain) {
2223
- if (!this.tables?.spamEmailDomain) {
2224
- throw new common.ProsopoDBError("DATABASE.DATABASE_IMPORT_ERROR", {
2225
- context: { failedFuncName: this.getSpamEmailDomain.name }
2226
- });
2227
- }
2228
- const suffixCandidates = util.buildDomainSuffixCandidates(domain).slice(
2229
- 0,
2230
- MAX_DOMAIN_SUFFIX_CANDIDATES
2231
- );
2232
- const query = suffixCandidates.length > 0 ? { domain: { $in: suffixCandidates } } : { domain };
2233
- return await this.tables.spamEmailDomain.findOne(query).exec();
2234
- }
2235
- async bulkUpdateSpamEmailDomains(domains, upsert) {
2236
- if (!this.tables?.spamEmailDomain) {
2237
- throw new common.ProsopoDBError("DATABASE.DATABASE_IMPORT_ERROR", {
2238
- context: { failedFuncName: this.bulkUpdateSpamEmailDomains.name }
2239
- });
2240
- }
2241
- const bulkOps = domains.map((op) => ({
2242
- updateOne: {
2243
- filter: op.filter,
2244
- update: { $set: op.update },
2245
- upsert
2246
- }
2247
- }));
2248
- if (bulkOps.length > 0) {
2249
- await this.tables.spamEmailDomain.bulkWrite(bulkOps);
2250
- }
2251
- }
2252
- }
84
+ var ProviderDatabase = class extends require_mongo.MongoDatabase {
85
+ constructor(options) {
86
+ super(options.mongo.url, options.mongo.dbname, options.mongo.authSource, options.logger);
87
+ this.options = options;
88
+ this.tables = {};
89
+ this.indexesEnsured = false;
90
+ this.tables = {};
91
+ this.redisAccessRulesConnection = null;
92
+ this.redisConnection = null;
93
+ this.userAccessRulesStorage = null;
94
+ }
95
+ setCentralDbStreamer(streamer) {
96
+ this.centralStreamer = streamer;
97
+ }
98
+ hasCentralDbStreamer() {
99
+ return this.centralStreamer !== void 0;
100
+ }
101
+ async connect() {
102
+ await super.connect();
103
+ this.loadTables();
104
+ await this.setupRedis();
105
+ }
106
+ async setupRedis() {
107
+ this.redisConnection = (0, _prosopo_redis_client.connectToRedis)({
108
+ url: this.options.redis?.url,
109
+ password: this.options.redis?.password,
110
+ logger: this.logger
111
+ });
112
+ this.redisAccessRulesConnection = (0, _prosopo_redis_client.setupRedisIndex)(this.redisConnection, {
113
+ ..._prosopo_user_access_policy_redis.accessRulesRedisIndex,
114
+ name: this.options.redis?.indexName || _prosopo_user_access_policy_redis.accessRulesRedisIndex.name
115
+ }, this.logger);
116
+ this.userAccessRulesStorage = (0, _prosopo_user_access_policy_redis.createRedisAccessRulesStorage)(this.redisAccessRulesConnection, this.logger);
117
+ }
118
+ loadTables() {
119
+ const tables = {};
120
+ PROVIDER_TABLES.map(({ collectionName, modelName, schema }) => {
121
+ if (this.connection) tables[collectionName] = this.connection.model(modelName, schema);
122
+ });
123
+ this.tables = tables;
124
+ }
125
+ getTables() {
126
+ if (!this.tables) throw new _prosopo_common.ProsopoDBError("DATABASE.TABLES_UNDEFINED", {
127
+ context: { failedFuncName: this.getTables.name },
128
+ logger: this.logger
129
+ });
130
+ return this.tables;
131
+ }
132
+ getRedisAccessRulesConnection() {
133
+ if (null === this.redisAccessRulesConnection) throw new _prosopo_common.ProsopoDBError("DATABASE.REDIS_ACCESS_RULES_CONNECTION_UNDEFINED");
134
+ return this.redisAccessRulesConnection;
135
+ }
136
+ getRedisConnection() {
137
+ if (null === this.redisConnection) throw new _prosopo_common.ProsopoDBError("DATABASE.REDIS_ACCESS_RULES_CONNECTION_UNDEFINED");
138
+ return this.redisConnection;
139
+ }
140
+ async ensureIndexes() {
141
+ const indexPromises = [];
142
+ if (!this.indexesEnsured) PROVIDER_TABLES.map(({ collectionName }) => {
143
+ indexPromises.push(new Promise((resolve) => {
144
+ if (this.connected) this.tables[collectionName].collection.dropIndexes().then(() => {
145
+ this.tables[collectionName].ensureIndexes().then(() => {
146
+ this.logger.info(() => ({ msg: `Indexes ensured for collection ${collectionName}` }));
147
+ resolve();
148
+ }).catch((err) => {
149
+ this.logger.warn(() => ({
150
+ err,
151
+ msg: `Error creating indexes for collection ${collectionName}`
152
+ }));
153
+ resolve();
154
+ });
155
+ });
156
+ else {
157
+ this.logger.info(() => ({ msg: `Skipping index creation for collection ${collectionName} as not connected` }));
158
+ resolve();
159
+ }
160
+ }));
161
+ });
162
+ await Promise.all(indexPromises);
163
+ this.indexesEnsured = true;
164
+ }
165
+ getUserAccessRulesStorage() {
166
+ if (null === this.userAccessRulesStorage) throw new _prosopo_common.ProsopoDBError("DATABASE.USER_ACCESS_RULES_STORAGE_UNDEFINED");
167
+ return this.userAccessRulesStorage;
168
+ }
169
+ /**
170
+ * @description Load a dataset to the database
171
+ * @param {Dataset} dataset
172
+ */
173
+ async storeDataset(dataset) {
174
+ try {
175
+ this.logger.debug(() => ({
176
+ data: { datasetId: dataset.datasetId },
177
+ msg: "Storing dataset in database"
178
+ }));
179
+ const parsedDataset = _prosopo_types.DatasetWithIdsAndTreeSchema.parse(dataset);
180
+ const datasetDoc = {
181
+ datasetId: parsedDataset.datasetId,
182
+ datasetContentId: parsedDataset.datasetContentId,
183
+ format: parsedDataset.format,
184
+ contentTree: parsedDataset.contentTree,
185
+ solutionTree: parsedDataset.solutionTree
186
+ };
187
+ const filter = { datasetId: parsedDataset.datasetId };
188
+ await this.tables.dataset?.updateOne(filter, { $set: datasetDoc }, { upsert: true });
189
+ const captchaDocs = parsedDataset.captchas.map(({ solution, ...captcha }, index) => ({
190
+ ...captcha,
191
+ datasetId: parsedDataset.datasetId,
192
+ datasetContentId: parsedDataset.datasetContentId,
193
+ index,
194
+ solved: !!solution?.length
195
+ }));
196
+ this.logger.debug(() => ({ msg: "Inserting captcha records" }));
197
+ if (captchaDocs.length) await this.tables?.captcha.bulkWrite(captchaDocs.map((captchaDoc) => ({ updateOne: {
198
+ filter: { captchaId: captchaDoc.captchaId },
199
+ update: {
200
+ $set: captchaDoc,
201
+ $setOnInsert: { randomKey: Math.random() }
202
+ },
203
+ upsert: true
204
+ } })));
205
+ const captchaSolutionDocs = parsedDataset.captchas.filter(({ solution }) => solution?.length).map((captcha) => ({
206
+ captchaId: captcha.captchaId,
207
+ captchaContentId: captcha.captchaContentId,
208
+ solution: captcha.solution,
209
+ salt: captcha.salt,
210
+ datasetId: parsedDataset.datasetId,
211
+ datasetContentId: parsedDataset.datasetContentId
212
+ }));
213
+ this.logger.debug(() => ({ msg: "Inserting solution records" }));
214
+ if (captchaSolutionDocs.length) await this.tables?.solution.bulkWrite(captchaSolutionDocs.map((captchaSolutionDoc) => ({ updateOne: {
215
+ filter: { captchaId: captchaSolutionDoc.captchaId },
216
+ update: { $set: captchaSolutionDoc },
217
+ upsert: true
218
+ } })));
219
+ this.logger.debug(() => ({ msg: "Dataset stored in database" }));
220
+ } catch (err) {
221
+ throw new _prosopo_common.ProsopoDBError("DATABASE.DATASET_LOAD_FAILED", {
222
+ context: {
223
+ failedFuncName: this.storeDataset.name,
224
+ error: err
225
+ },
226
+ logger: this.logger
227
+ });
228
+ }
229
+ }
230
+ /** @description Get solutions for a dataset
231
+ * @param {string} datasetId
232
+ */
233
+ async getSolutions(datasetId) {
234
+ const filter = { datasetId };
235
+ const docs = await this.tables?.solution.find(filter).lean();
236
+ return docs ? docs : [];
237
+ }
238
+ /** @description Get a solution by captcha id
239
+ * @param {string} captchaId
240
+ */
241
+ async getSolutionByCaptchaId(captchaId) {
242
+ const filter = { captchaId };
243
+ return await this.tables?.solution.findOne(filter).lean() || null;
244
+ }
245
+ /** @description Get a dataset from the database
246
+ * @param {string} datasetId
247
+ */
248
+ async getDataset(datasetId) {
249
+ const filter = { datasetId };
250
+ const datasetDoc = await this.tables?.dataset.findOne(filter).lean();
251
+ if (datasetDoc) {
252
+ const { datasetContentId, format, contentTree, solutionTree } = datasetDoc;
253
+ const captchas = await this.tables?.captcha.find(filter).lean() || [];
254
+ const solutions = await this.tables?.solution.find(filter).lean() || [];
255
+ const solutionsKeyed = {};
256
+ for (const solution of solutions) solutionsKeyed[solution.captchaId] = solution;
257
+ return {
258
+ datasetId,
259
+ datasetContentId,
260
+ format,
261
+ contentTree: contentTree || [],
262
+ solutionTree: solutionTree || [],
263
+ captchas: captchas.map((captchaDoc) => {
264
+ const { captchaId, captchaContentId, items, target, salt, solved } = captchaDoc;
265
+ const solution = solutionsKeyed[captchaId];
266
+ return {
267
+ captchaId,
268
+ captchaContentId,
269
+ solved: !!solved,
270
+ salt,
271
+ items,
272
+ target,
273
+ solution: solved && solution ? solution.solution : []
274
+ };
275
+ })
276
+ };
277
+ }
278
+ throw new _prosopo_common.ProsopoDBError("DATABASE.DATASET_GET_FAILED", { context: {
279
+ failedFuncName: this.getDataset.name,
280
+ datasetId
281
+ } });
282
+ }
283
+ /**
284
+ * @description Get random captchas that are solved or not solved
285
+ * @param {boolean} solved `true` when captcha is solved
286
+ * @param {string} datasetId the id of the data set
287
+ * @param {number} size the number of records to be returned
288
+ */
289
+ async getRandomCaptcha(solved, datasetId, size) {
290
+ if (!(0, _polkadot_util_is.isHex)(datasetId)) throw new _prosopo_common.ProsopoDBError("DATABASE.INVALID_HASH", { context: {
291
+ failedFuncName: this.getRandomCaptcha.name,
292
+ datasetId
293
+ } });
294
+ const sampleSize = size ? Math.abs(Math.trunc(size)) : 1;
295
+ const projection = {
296
+ _id: 0,
297
+ datasetId: 1,
298
+ datasetContentId: 1,
299
+ captchaId: 1,
300
+ captchaContentId: 1,
301
+ items: 1,
302
+ target: 1
303
+ };
304
+ const baseFilter = {
305
+ datasetId,
306
+ solved
307
+ };
308
+ const pivot = Math.random();
309
+ let docs = await this.tables?.captcha.find({
310
+ ...baseFilter,
311
+ randomKey: { $gte: pivot }
312
+ }, projection).sort({ randomKey: 1 }).limit(sampleSize).lean() ?? [];
313
+ if (docs.length < sampleSize) {
314
+ const tail = await this.tables?.captcha.find({
315
+ ...baseFilter,
316
+ randomKey: { $lt: pivot }
317
+ }, projection).sort({ randomKey: 1 }).limit(sampleSize - docs.length).lean() ?? [];
318
+ docs = docs.concat(tail);
319
+ }
320
+ if (docs.length === 0) {
321
+ const fallback = await this.tables?.captcha.aggregate([
322
+ { $match: baseFilter },
323
+ { $sample: { size: sampleSize } },
324
+ { $project: projection }
325
+ ]).exec() ?? [];
326
+ if (fallback.length > 0) return fallback.map(({ _id, ...keep }) => keep);
327
+ }
328
+ if (docs.length > 0) return docs;
329
+ throw new _prosopo_common.ProsopoDBError("DATABASE.CAPTCHA_GET_FAILED", { context: {
330
+ failedFuncName: this.getRandomCaptcha.name,
331
+ solved,
332
+ datasetId,
333
+ size
334
+ } });
335
+ }
336
+ /**
337
+ * @description Get captchas by id
338
+ * @param {string[]} captchaId
339
+ */
340
+ async getCaptchaById(captchaId) {
341
+ const filter = { captchaId: { $in: captchaId } };
342
+ const docs = await this.tables?.captcha.find(filter, {
343
+ _id: 0,
344
+ captchaId: 1,
345
+ datasetId: 1,
346
+ items: 1
347
+ }).lean();
348
+ if (docs?.length) return docs.map(({ _id, ...keepAttrs }) => keepAttrs);
349
+ throw new _prosopo_common.ProsopoDBError("DATABASE.CAPTCHA_GET_FAILED", { context: {
350
+ failedFuncName: this.getCaptchaById.name,
351
+ captchaId
352
+ } });
353
+ }
354
+ /**
355
+ * @description Update a captcha
356
+ * @param {Captcha} captcha
357
+ * @param {string} datasetId the id of the data set
358
+ */
359
+ async updateCaptcha(captcha, datasetId) {
360
+ if (!(0, _polkadot_util_is.isHex)(datasetId)) throw new _prosopo_common.ProsopoDBError("DATABASE.INVALID_HASH", { context: {
361
+ failedFuncName: this.updateCaptcha.name,
362
+ datasetId
363
+ } });
364
+ try {
365
+ const filter = { datasetId };
366
+ await this.tables?.captcha.updateOne(filter, { $set: captcha }, { upsert: false });
367
+ } catch (err) {
368
+ throw new _prosopo_common.ProsopoDBError("DATABASE.CAPTCHA_UPDATE_FAILED", { context: {
369
+ failedFuncName: this.getDatasetDetails.name,
370
+ error: err
371
+ } });
372
+ }
373
+ }
374
+ /**
375
+ * @description Remove captchas
376
+ */
377
+ async removeCaptchas(captchaIds) {
378
+ const filter = { captchaId: { $in: captchaIds } };
379
+ await this.tables?.captcha.deleteMany(filter);
380
+ }
381
+ /**
382
+ * @description Get a dataset by Id
383
+ */
384
+ /**
385
+ * Returns the most recently uploaded dataset's ID. Used as the per-provider
386
+ * default when a client doesn't pin a specific dataset (which it can't
387
+ * under DNS-based routing — clients don't know which provider they'll hit).
388
+ */
389
+ async getMostRecentDatasetId() {
390
+ const datasetId = (await this.tables?.dataset.findOne().sort({ _id: -1 }).lean())?.datasetId;
391
+ return typeof datasetId === "string" ? datasetId : void 0;
392
+ }
393
+ async getDatasetDetails(datasetId) {
394
+ if (!(0, _polkadot_util_is.isHex)(datasetId)) throw new _prosopo_common.ProsopoDBError("DATABASE.INVALID_HASH", { context: {
395
+ failedFuncName: this.getDatasetDetails.name,
396
+ datasetId
397
+ } });
398
+ const filter = { datasetId };
399
+ const doc = await this.tables?.dataset.findOne(filter).lean();
400
+ if (doc) return doc;
401
+ throw new _prosopo_common.ProsopoDBError("DATABASE.DATASET_GET_FAILED", { context: {
402
+ failedFuncName: this.getDatasetDetails.name,
403
+ datasetId
404
+ } });
405
+ }
406
+ /**
407
+ * @description Store a Dapp User's captcha solution commitment
408
+ */
409
+ async storeUserImageCaptchaSolution(captchas, commit) {
410
+ const commitmentRecord = _prosopo_types.UserCommitmentSchema.parse({
411
+ ...commit,
412
+ lastUpdatedTimestamp: /* @__PURE__ */ new Date()
413
+ });
414
+ if (captchas.length) {
415
+ const filter = { id: commit.id };
416
+ await this.tables?.commitment.updateOne(filter, commitmentRecord, { upsert: true });
417
+ const ops = captchas.map((captcha) => ({ updateOne: {
418
+ filter: {
419
+ commitmentId: commit.id,
420
+ captchaId: captcha.captchaId
421
+ },
422
+ update: { $set: {
423
+ captchaId: captcha.captchaId,
424
+ captchaContentId: captcha.captchaContentId,
425
+ salt: captcha.salt,
426
+ solution: captcha.solution,
427
+ commitmentId: commit.id,
428
+ processed: false
429
+ } },
430
+ upsert: true
431
+ } }));
432
+ await this.tables?.usersolution.bulkWrite(ops);
433
+ this.centralStreamer?.streamImageRecord(commitmentRecord, (ts) => this.tables.commitment.updateOne({ id: commit.id }, { $set: { storedAtTimestamp: ts } }).then(() => {}));
434
+ }
435
+ }
436
+ /**
437
+ * @description Adds a new PoW Captcha record to the database.
438
+ * @param {string} challenge The challenge string for the captcha.
439
+ * @param components The components of the PoW challenge.
440
+ * @param difficulty
441
+ * @param providerSignature
442
+ * @param ipAddress
443
+ * @param headers
444
+ * @param ja4
445
+ * @param sessionId
446
+ * @param serverChecked
447
+ * @param userSubmitted
448
+ * @param storedStatus
449
+ * @param userSignature
450
+ * @param ipInfo full ipinfo payload from the ipInfoMiddleware
451
+ * @returns {Promise<void>} A promise that resolves when the record is added.
452
+ */
453
+ async storePowCaptchaRecord(challenge, components, difficulty, providerSignature, ipAddress, headers, ja4, sessionId, serverChecked = false, userSubmitted = false, userSignature, ipInfo) {
454
+ const tables = this.getTables();
455
+ const powCaptchaRecord = {
456
+ challenge,
457
+ userAccount: components.userAccount,
458
+ dappAccount: components.dappAccount,
459
+ requestedAtTimestamp: new Date(components.requestedAtTimestamp),
460
+ ipAddress,
461
+ headers,
462
+ ja4,
463
+ result: { status: _prosopo_types.CaptchaStatus.pending },
464
+ userSubmitted,
465
+ serverChecked,
466
+ difficulty,
467
+ providerSignature,
468
+ userSignature,
469
+ lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
470
+ pendingStage: true,
471
+ sessionId,
472
+ ipInfo
473
+ };
474
+ try {
475
+ await tables.powcaptcha.create(powCaptchaRecord);
476
+ this.logger.info(() => ({
477
+ data: {
478
+ challenge,
479
+ userSubmitted,
480
+ serverChecked,
481
+ countryCode: ipInfo?.isValid ? ipInfo.countryCode : void 0
482
+ },
483
+ msg: "PowCaptcha record added successfully"
484
+ }));
485
+ this.centralStreamer?.streamPowRecord(powCaptchaRecord, (ts) => this.tables.powcaptcha.updateOne({
486
+ challenge,
487
+ lastUpdatedTimestamp: { $lte: ts }
488
+ }, {
489
+ $set: { storedAtTimestamp: ts },
490
+ $unset: { pendingStage: 1 }
491
+ }).then(() => {}));
492
+ } catch (error) {
493
+ const err = new _prosopo_common.ProsopoDBError("DATABASE.CAPTCHA_UPDATE_FAILED", {
494
+ context: {
495
+ error,
496
+ challenge,
497
+ userSubmitted,
498
+ serverChecked,
499
+ ipInfo
500
+ },
501
+ logger: this.logger
502
+ });
503
+ this.logger.error(() => ({
504
+ err: error,
505
+ msg: "Failed to add PowCaptcha record"
506
+ }));
507
+ throw err;
508
+ }
509
+ }
510
+ /**
511
+ * @description Retrieves a PoW Captcha record by its challenge string.
512
+ * @param {string} challenge The challenge string to search for.
513
+ * @returns {Promise<PoWCaptchaRecord | null>} A promise that resolves with the found record or null if not found.
514
+ */
515
+ async getPowCaptchaRecordByChallenge(challenge) {
516
+ if (!this.tables) throw new _prosopo_common.ProsopoDBError("DATABASE.DATABASE_UNDEFINED", {
517
+ context: { failedFuncName: this.getPowCaptchaRecordByChallenge.name },
518
+ logger: this.logger
519
+ });
520
+ try {
521
+ const filter = { challenge };
522
+ const record = await this.tables.powcaptcha.findOne(filter, {
523
+ challenge: 1,
524
+ userAccount: 1,
525
+ dappAccount: 1,
526
+ requestedAtTimestamp: 1,
527
+ submittedAtTimestamp: 1,
528
+ verifiedAtTimestamp: 1,
529
+ failedAtTimestamp: 1,
530
+ ipAddress: 1,
531
+ headers: 1,
532
+ ja4: 1,
533
+ result: 1,
534
+ difficulty: 1,
535
+ sessionId: 1,
536
+ ipInfo: 1,
537
+ deviceCapability: 1,
538
+ behavioralDataPacked: 1,
539
+ serverChecked: 1,
540
+ userSubmitted: 1,
541
+ coords: 1
542
+ }).lean();
543
+ if (record) {
544
+ this.logger.info(() => ({
545
+ data: { challenge },
546
+ msg: "PowCaptcha record retrieved successfully"
547
+ }));
548
+ return record;
549
+ }
550
+ this.logger.info(() => ({
551
+ data: { challenge },
552
+ msg: "No PowCaptcha record found"
553
+ }));
554
+ return null;
555
+ } catch (error) {
556
+ const err = new _prosopo_common.ProsopoDBError("DATABASE.CAPTCHA_GET_FAILED", {
557
+ context: {
558
+ error,
559
+ challenge
560
+ },
561
+ logger: this.logger
562
+ });
563
+ this.logger.error(() => ({
564
+ err,
565
+ msg: "Failed to retrieve PowCaptcha record"
566
+ }));
567
+ throw err;
568
+ }
569
+ }
570
+ /**
571
+ * @description Updates a PoW Captcha record in the database.
572
+ * @param {string} challenge The challenge string of the captcha to be updated.
573
+ * @param result
574
+ * @param serverChecked
575
+ * @param userSubmitted
576
+ * @param userSignature
577
+ * @returns {Promise<void>} A promise that resolves when the record is updated.
578
+ */
579
+ async updatePowCaptchaRecordResult(challenge, result, serverChecked = false, userSubmitted = false, userSignature, coords) {
580
+ const tables = this.getTables();
581
+ const timestamp = /* @__PURE__ */ new Date();
582
+ const isDisapproved = result.status === _prosopo_types.CaptchaStatus.disapproved;
583
+ (0, _prosopo_util.assertCoordsSafe)(coords, "coords");
584
+ const setStage = {
585
+ result,
586
+ serverChecked,
587
+ userSubmitted,
588
+ userSignature,
589
+ lastUpdatedTimestamp: timestamp,
590
+ pendingStage: true,
591
+ ...coords && { coords }
592
+ };
593
+ if (userSubmitted) setStage.submittedAtTimestamp = { $ifNull: ["$submittedAtTimestamp", timestamp] };
594
+ if (isDisapproved) setStage.failedAtTimestamp = { $ifNull: ["$failedAtTimestamp", timestamp] };
595
+ try {
596
+ if ((await tables.powcaptcha.updateOne({ challenge }, [{ $set: setStage }])).matchedCount === 0) {
597
+ const err = new _prosopo_common.ProsopoDBError("DATABASE.CAPTCHA_GET_FAILED", {
598
+ context: {
599
+ challenge,
600
+ ...setStage
601
+ },
602
+ logger: this.logger
603
+ });
604
+ this.logger.info(() => ({
605
+ err,
606
+ msg: "No PowCaptcha record found to update"
607
+ }));
608
+ throw err;
609
+ }
610
+ this.logger.info(() => ({
611
+ data: {
612
+ challenge,
613
+ ...setStage
614
+ },
615
+ msg: "PowCaptcha record updated successfully"
616
+ }));
617
+ this.centralStreamer?.streamPowUpdate(() => this.getPowCaptchaRecordByChallenge(challenge), (ts) => this.tables.powcaptcha.updateOne({
618
+ challenge,
619
+ lastUpdatedTimestamp: { $lte: ts }
620
+ }, {
621
+ $set: { storedAtTimestamp: ts },
622
+ $unset: { pendingStage: 1 }
623
+ }).then(() => {}));
624
+ } catch (error) {
625
+ const err = new _prosopo_common.ProsopoDBError("DATABASE.CAPTCHA_UPDATE_FAILED", {
626
+ context: {
627
+ error,
628
+ challenge,
629
+ ...setStage
630
+ },
631
+ logger: this.logger
632
+ });
633
+ this.logger.error(() => ({
634
+ err,
635
+ msg: "Failed to update PowCaptcha record"
636
+ }));
637
+ throw err;
638
+ }
639
+ }
640
+ async updatePowCaptchaRecord(challenge, updates) {
641
+ await this.getTables().powcaptcha.updateOne({ challenge }, { $set: {
642
+ ...updates,
643
+ pendingStage: true
644
+ } }, { upsert: false });
645
+ this.centralStreamer?.streamPowUpdate(() => this.getPowCaptchaRecordByChallenge(challenge), (ts) => this.tables.powcaptcha.updateOne({
646
+ challenge,
647
+ lastUpdatedTimestamp: { $lte: ts }
648
+ }, {
649
+ $set: { storedAtTimestamp: ts },
650
+ $unset: { pendingStage: 1 }
651
+ }).then(() => {}));
652
+ }
653
+ async storePuzzleCaptchaRecord(challenge, components, targetX, targetY, originX, originY, tolerance, providerSignature, ipAddress, headers, ja4, sessionId, ipInfo) {
654
+ const tables = this.getTables();
655
+ const puzzleCaptchaRecord = {
656
+ challenge,
657
+ userAccount: components.userAccount,
658
+ dappAccount: components.dappAccount,
659
+ requestedAtTimestamp: new Date(components.requestedAtTimestamp),
660
+ ipAddress,
661
+ headers,
662
+ ja4,
663
+ result: { status: _prosopo_types.CaptchaStatus.pending },
664
+ userSubmitted: false,
665
+ serverChecked: false,
666
+ targetX,
667
+ targetY,
668
+ originX,
669
+ originY,
670
+ tolerance,
671
+ providerSignature,
672
+ lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
673
+ pendingStage: true,
674
+ sessionId,
675
+ ipInfo
676
+ };
677
+ try {
678
+ await tables.puzzlecaptcha.create(puzzleCaptchaRecord);
679
+ this.logger.info(() => ({
680
+ data: {
681
+ challenge,
682
+ countryCode: ipInfo?.isValid ? ipInfo.countryCode : void 0
683
+ },
684
+ msg: "PuzzleCaptcha record added successfully"
685
+ }));
686
+ this.centralStreamer?.streamPuzzleRecord(puzzleCaptchaRecord, (ts) => this.tables.puzzlecaptcha.updateOne({
687
+ challenge,
688
+ lastUpdatedTimestamp: { $lte: ts }
689
+ }, {
690
+ $set: { storedAtTimestamp: ts },
691
+ $unset: { pendingStage: 1 }
692
+ }).then(() => {}));
693
+ } catch (error) {
694
+ const err = new _prosopo_common.ProsopoDBError("DATABASE.CAPTCHA_UPDATE_FAILED", {
695
+ context: {
696
+ error,
697
+ challenge,
698
+ ipInfo
699
+ },
700
+ logger: this.logger
701
+ });
702
+ this.logger.error(() => ({
703
+ err: error,
704
+ msg: "Failed to add PuzzleCaptcha record"
705
+ }));
706
+ throw err;
707
+ }
708
+ }
709
+ /**
710
+ * @description Retrieves a Puzzle Captcha record by its challenge string.
711
+ * @param {string} challenge The challenge string to search for.
712
+ * @returns {Promise<PuzzleCaptchaRecord | null>} A promise that resolves with the found record or null if not found.
713
+ */
714
+ async getPuzzleCaptchaRecordByChallenge(challenge) {
715
+ if (!this.tables) throw new _prosopo_common.ProsopoDBError("DATABASE.DATABASE_UNDEFINED", {
716
+ context: { failedFuncName: this.getPuzzleCaptchaRecordByChallenge.name },
717
+ logger: this.logger
718
+ });
719
+ try {
720
+ const filter = { challenge };
721
+ const record = await this.tables.puzzlecaptcha.findOne(filter, {
722
+ challenge: 1,
723
+ userAccount: 1,
724
+ dappAccount: 1,
725
+ requestedAtTimestamp: 1,
726
+ submittedAtTimestamp: 1,
727
+ ipAddress: 1,
728
+ headers: 1,
729
+ ja4: 1,
730
+ result: 1,
731
+ targetX: 1,
732
+ targetY: 1,
733
+ originX: 1,
734
+ originY: 1,
735
+ tolerance: 1,
736
+ puzzleEvents: 1,
737
+ sessionId: 1,
738
+ ipInfo: 1,
739
+ deviceCapability: 1,
740
+ behavioralDataPacked: 1,
741
+ serverChecked: 1,
742
+ userSubmitted: 1,
743
+ coords: 1
744
+ }).lean();
745
+ if (record) {
746
+ this.logger.info(() => ({
747
+ data: { challenge },
748
+ msg: "PuzzleCaptcha record retrieved successfully"
749
+ }));
750
+ return record;
751
+ }
752
+ this.logger.info(() => ({
753
+ data: { challenge },
754
+ msg: "No PuzzleCaptcha record found"
755
+ }));
756
+ return null;
757
+ } catch (error) {
758
+ const err = new _prosopo_common.ProsopoDBError("DATABASE.CAPTCHA_GET_FAILED", {
759
+ context: {
760
+ error,
761
+ challenge
762
+ },
763
+ logger: this.logger
764
+ });
765
+ this.logger.error(() => ({
766
+ err,
767
+ msg: "Failed to retrieve PuzzleCaptcha record"
768
+ }));
769
+ throw err;
770
+ }
771
+ }
772
+ /**
773
+ * @description Updates a Puzzle Captcha record result in the database.
774
+ * @param {string} challenge The challenge string of the captcha to be updated.
775
+ * @param result
776
+ * @param serverChecked
777
+ * @param userSubmitted
778
+ * @param userSignature
779
+ * @param coords
780
+ * @param lastUpdatedTimestamp
781
+ * @returns {Promise<void>} A promise that resolves when the record is updated.
782
+ */
783
+ async updatePuzzleCaptchaRecordResult(challenge, result, serverChecked = false, userSubmitted = false, userSignature, coords, lastUpdatedTimestamp) {
784
+ const tables = this.getTables();
785
+ const timestamp = lastUpdatedTimestamp ?? /* @__PURE__ */ new Date();
786
+ const isDisapproved = result.status === _prosopo_types.CaptchaStatus.disapproved;
787
+ (0, _prosopo_util.assertCoordsSafe)(coords, "coords");
788
+ const setStage = {
789
+ result,
790
+ serverChecked,
791
+ userSubmitted,
792
+ userSignature,
793
+ lastUpdatedTimestamp: timestamp,
794
+ pendingStage: true,
795
+ ...coords && { coords },
796
+ ...userSubmitted && { submittedAtTimestamp: timestamp },
797
+ ...isDisapproved && { failedAtTimestamp: timestamp }
798
+ };
799
+ try {
800
+ if ((await tables.puzzlecaptcha.updateOne({ challenge }, { $set: setStage })).matchedCount === 0) {
801
+ const err = new _prosopo_common.ProsopoDBError("DATABASE.CAPTCHA_GET_FAILED", {
802
+ context: {
803
+ challenge,
804
+ ...setStage
805
+ },
806
+ logger: this.logger
807
+ });
808
+ this.logger.info(() => ({
809
+ err,
810
+ msg: "No PuzzleCaptcha record found to update"
811
+ }));
812
+ throw err;
813
+ }
814
+ this.logger.info(() => ({
815
+ data: {
816
+ challenge,
817
+ ...setStage
818
+ },
819
+ msg: "PuzzleCaptcha record updated successfully"
820
+ }));
821
+ this.centralStreamer?.streamPuzzleUpdate(() => this.getPuzzleCaptchaRecordByChallenge(challenge), (ts) => this.tables.puzzlecaptcha.updateOne({
822
+ challenge,
823
+ lastUpdatedTimestamp: { $lte: ts }
824
+ }, {
825
+ $set: { storedAtTimestamp: ts },
826
+ $unset: { pendingStage: 1 }
827
+ }).then(() => {}));
828
+ } catch (error) {
829
+ const err = new _prosopo_common.ProsopoDBError("DATABASE.CAPTCHA_UPDATE_FAILED", {
830
+ context: {
831
+ error,
832
+ challenge,
833
+ ...setStage
834
+ },
835
+ logger: this.logger
836
+ });
837
+ this.logger.error(() => ({
838
+ err,
839
+ msg: "Failed to update PuzzleCaptcha record"
840
+ }));
841
+ throw err;
842
+ }
843
+ }
844
+ async updatePuzzleCaptchaRecord(challenge, updates) {
845
+ const tables = this.getTables();
846
+ const timestamp = /* @__PURE__ */ new Date();
847
+ const baseSet = {
848
+ ...updates,
849
+ pendingStage: true,
850
+ ...updates.serverChecked === true && { verifiedAtTimestamp: timestamp },
851
+ ...updates.userSubmitted === true && { submittedAtTimestamp: timestamp },
852
+ ...updates.result?.status === _prosopo_types.CaptchaStatus.disapproved && { failedAtTimestamp: timestamp }
853
+ };
854
+ await tables.puzzlecaptcha.updateOne({ challenge }, { $set: baseSet });
855
+ this.centralStreamer?.streamPuzzleUpdate(() => this.getPuzzleCaptchaRecordByChallenge(challenge), (ts) => this.tables.puzzlecaptcha.updateOne({
856
+ challenge,
857
+ lastUpdatedTimestamp: { $lte: ts }
858
+ }, {
859
+ $set: { storedAtTimestamp: ts },
860
+ $unset: { pendingStage: 1 }
861
+ }).then(() => {}));
862
+ }
863
+ /** @description Get serverChecked Dapp User image captcha commitments from the commitments table
864
+ */
865
+ async getCheckedDappUserCommitments() {
866
+ const filter = { [_prosopo_types.StoredStatusNames.serverChecked]: true };
867
+ return await this.tables?.commitment.find(filter).lean() || [];
868
+ }
869
+ /** @description Get Dapp User captcha commitments from the commitments table that have not been counted towards the
870
+ * client's total.
871
+ *
872
+ * Served by the `pendingStage_partial` index. Records have
873
+ * `pendingStage: true` set on insert and on every mutation (see
874
+ * `updateDappUserCommitment`, `markDappUserCommitmentsChecked`,
875
+ * `approveDappUserCommitment`, `disapproveDappUserCommitment`,
876
+ * `storePendingImageCommitment`). `markDappUserCommitmentsStored` clears
877
+ * the flag after a successful stage, guarded by `lastUpdatedTimestamp`
878
+ * so an in-flight update isn't lost.
879
+ */
880
+ async getUnstoredDappUserCommitments(limit = 1e3, skip = 0) {
881
+ return await this.tables?.commitment.find({ pendingStage: true }).sort({ _id: 1 }).skip(skip).limit(limit).lean() || [];
882
+ }
883
+ /** @description Mark a list of captcha commits as stored.
884
+ *
885
+ * `asOfTimestamp` defaults to "now" but the sweep should pass the time
886
+ * at which it fetched the batch. The lastUpdatedTimestamp guard prevents
887
+ * us from clearing pendingStage on a record that was mutated between
888
+ * fetch and mark-stored — those records will leave pendingStage set so
889
+ * the next sweep picks them up.
890
+ */
891
+ async markDappUserCommitmentsStored(commitmentIds, asOfTimestamp = /* @__PURE__ */ new Date()) {
892
+ await this.tables?.commitment.updateMany({
893
+ id: { $in: commitmentIds },
894
+ lastUpdatedTimestamp: { $lte: asOfTimestamp }
895
+ }, {
896
+ $set: { storedAtTimestamp: /* @__PURE__ */ new Date() },
897
+ $unset: { pendingStage: 1 }
898
+ }, { upsert: false });
899
+ }
900
+ /** @description Mark a list of captcha commits as checked
901
+ */
902
+ async markDappUserCommitmentsChecked(commitmentIds) {
903
+ const timestamp = /* @__PURE__ */ new Date();
904
+ await this.tables?.commitment.updateMany({ id: { $in: commitmentIds } }, [{ $set: {
905
+ serverChecked: true,
906
+ lastUpdatedTimestamp: timestamp,
907
+ pendingStage: true,
908
+ verifiedAtTimestamp: { $ifNull: ["$verifiedAtTimestamp", timestamp] }
909
+ } }]);
910
+ }
911
+ /** @description Update an image captcha commitment
912
+ */
913
+ async updateDappUserCommitment(commitmentId, updates) {
914
+ const filter = { id: commitmentId };
915
+ const timestamp = /* @__PURE__ */ new Date();
916
+ const baseSet = {
917
+ ...updates,
918
+ lastUpdatedAtTimestamp: timestamp,
919
+ pendingStage: true
920
+ };
921
+ const pipelineExprs = {};
922
+ if (updates.userSubmitted === true) pipelineExprs.submittedAtTimestamp = { $ifNull: ["$submittedAtTimestamp", timestamp] };
923
+ if (updates.serverChecked === true) pipelineExprs.verifiedAtTimestamp = { $ifNull: ["$verifiedAtTimestamp", timestamp] };
924
+ if (updates.result?.status === _prosopo_types.CaptchaStatus.disapproved) pipelineExprs.failedAtTimestamp = { $ifNull: ["$failedAtTimestamp", timestamp] };
925
+ if (Object.keys(pipelineExprs).length === 0) {
926
+ await this.tables?.commitment.updateOne(filter, { $set: baseSet });
927
+ return;
928
+ }
929
+ await this.tables?.commitment.updateOne(filter, [{ $set: {
930
+ ...baseSet,
931
+ ...pipelineExprs
932
+ } }]);
933
+ }
934
+ /**
935
+ * @description Get Dapp User PoW captcha commitments that have not been counted towards the client's total
936
+ * @param {number} limit Maximum number of records to return
937
+ * @param {number} skip Number of records to skip (for pagination)
938
+ * @returns {Promise<PoWCaptchaRecord[]>} Array of PoW captcha records
939
+ */
940
+ async getUnstoredDappUserPoWCommitments(limit = 1e3, skip = 0) {
941
+ return await this.tables?.powcaptcha.find({ pendingStage: true }).sort({ _id: 1 }).skip(skip).limit(limit).lean() || [];
942
+ }
943
+ /** @description Mark a list of PoW captcha commits as stored.
944
+ *
945
+ * `asOfTimestamp` defaults to "now" but the sweep should pass the time
946
+ * at which it fetched the batch. See markDappUserCommitmentsStored for
947
+ * the guard rationale.
948
+ */
949
+ async markDappUserPoWCommitmentsStored(challenges, asOfTimestamp = /* @__PURE__ */ new Date()) {
950
+ await this.tables?.powcaptcha.updateMany({
951
+ challenge: { $in: challenges },
952
+ lastUpdatedTimestamp: { $lte: asOfTimestamp }
953
+ }, {
954
+ $set: { storedAtTimestamp: /* @__PURE__ */ new Date() },
955
+ $unset: { pendingStage: 1 }
956
+ }, { upsert: false });
957
+ }
958
+ /** @description Mark a list of PoW captcha commits as checked by the server
959
+ */
960
+ async markDappUserPoWCommitmentsChecked(challenges) {
961
+ const timestamp = /* @__PURE__ */ new Date();
962
+ await this.tables?.powcaptcha.updateMany({ challenge: { $in: challenges } }, [{ $set: {
963
+ serverChecked: true,
964
+ lastUpdatedTimestamp: timestamp,
965
+ pendingStage: true,
966
+ verifiedAtTimestamp: { $ifNull: ["$verifiedAtTimestamp", timestamp] }
967
+ } }], { upsert: false });
968
+ }
969
+ /**
970
+ * Store a new session record
971
+ */
972
+ async storeSessionRecord(sessionRecord) {
973
+ try {
974
+ this.logger.debug(() => ({ data: {
975
+ action: "storing",
976
+ sessionRecord
977
+ } }));
978
+ const recordWithFlag = {
979
+ ...sessionRecord,
980
+ lastUpdatedTimestamp: sessionRecord.lastUpdatedTimestamp ?? sessionRecord.createdAt,
981
+ pendingStage: true
982
+ };
983
+ await this.tables.session.create(recordWithFlag);
984
+ this.centralStreamer?.streamSessionRecord(recordWithFlag, (ts) => this.tables.session.updateOne({
985
+ sessionId: sessionRecord.sessionId,
986
+ lastUpdatedTimestamp: { $lte: ts }
987
+ }, {
988
+ $set: { storedAtTimestamp: ts },
989
+ $unset: { pendingStage: 1 }
990
+ }).then(() => {}));
991
+ } catch (err) {
992
+ throw new _prosopo_common.ProsopoDBError("DATABASE.SESSION_STORE_FAILED", {
993
+ context: {
994
+ error: err,
995
+ sessionId: sessionRecord.sessionId
996
+ },
997
+ logger: this.logger
998
+ });
999
+ }
1000
+ }
1001
+ /**
1002
+ * Persist a synthetic Session record for a request that was 401'd at the
1003
+ * request-time block middleware. The record is written with
1004
+ * `blocked=true` + `deleted=true` so the captcha flow can't pick it up
1005
+ * downstream, and with sentinel values for the schema-required fields
1006
+ * the blocked request never had a chance to populate (score / threshold /
1007
+ * captchaType / etc.).
1008
+ *
1009
+ * Designed for fire-and-forget: callers `void`-cast the promise and any
1010
+ * Mongo error is swallowed-and-logged so the 401 response is never
1011
+ * delayed by a persistence failure. The structured log line at the call
1012
+ * site is the source of truth either way.
1013
+ *
1014
+ * Mirrors `storeSessionRecord` enough that the Traffic-page
1015
+ * aggregations can read these records out of the central-streamer
1016
+ * pipeline once the UI work lands.
1017
+ */
1018
+ async storeBlockedSession(record) {
1019
+ try {
1020
+ const stored = {
1021
+ ...record,
1022
+ blocked: true,
1023
+ deleted: true,
1024
+ storedAtTimestamp: record.storedAtTimestamp ?? record.createdAt,
1025
+ lastUpdatedTimestamp: record.lastUpdatedTimestamp ?? record.createdAt
1026
+ };
1027
+ await this.tables.session.create(stored);
1028
+ this.centralStreamer?.streamSessionRecord(stored, (ts) => this.tables.session.updateOne({
1029
+ sessionId: stored.sessionId,
1030
+ lastUpdatedTimestamp: { $lte: ts }
1031
+ }, { $set: { storedAtTimestamp: ts } }).then(() => {}));
1032
+ } catch (err) {
1033
+ this.logger.warn(() => ({
1034
+ err,
1035
+ msg: "Failed to persist blocked session record",
1036
+ data: {
1037
+ sessionId: record.sessionId,
1038
+ siteKey: record.siteKey
1039
+ }
1040
+ }));
1041
+ }
1042
+ }
1043
+ /**
1044
+ * Get a session record by sessionId
1045
+ */
1046
+ async getSessionRecordBySessionId(sessionId) {
1047
+ const filter = { sessionId };
1048
+ return await this.tables.session.findOne(filter, {
1049
+ sessionId: 1,
1050
+ token: 1,
1051
+ score: 1,
1052
+ threshold: 1,
1053
+ scoreComponents: 1,
1054
+ ipAddress: 1,
1055
+ ipInfo: 1,
1056
+ webView: 1,
1057
+ iFrame: 1,
1058
+ isEscalation: 1,
1059
+ decryptedHeadHash: 1,
1060
+ siteKey: 1,
1061
+ reason: 1,
1062
+ mode: 1,
1063
+ solvedImagesCount: 1,
1064
+ userSitekeyIpHash: 1,
1065
+ simdReadings: 1,
1066
+ dnsEvent: 1,
1067
+ currentUrl: 1,
1068
+ iframeUrl: 1,
1069
+ captchaType: 1,
1070
+ "headers.user-agent": 1,
1071
+ "headers.accept": 1,
1072
+ "headers.accept-language": 1,
1073
+ "headers.accept-encoding": 1,
1074
+ "headers.sec-ch-ua": 1,
1075
+ "headers.sec-ch-ua-mobile": 1,
1076
+ "headers.sec-ch-ua-platform": 1,
1077
+ "headers.sec-ch-ua-platform-version": 1,
1078
+ "headers.sec-fetch-dest": 1,
1079
+ "headers.sec-fetch-mode": 1,
1080
+ "headers.sec-fetch-site": 1,
1081
+ "headers.sec-fetch-user": 1,
1082
+ "headers.referer": 1,
1083
+ "headers.origin": 1,
1084
+ "headers.prosopo-user": 1,
1085
+ "headers.prosopo-site-key": 1,
1086
+ "headers.prosopo-type": 1,
1087
+ "headers.x-tls-version": 1
1088
+ }).lean() || void 0;
1089
+ }
1090
+ /**
1091
+ * Get a session record by token
1092
+ * Used to ensure that a token is not used more than once.
1093
+ */
1094
+ async getSessionRecordByToken(token) {
1095
+ const filter = { token };
1096
+ return await this.tables.session.findOne(filter).lean() || void 0;
1097
+ }
1098
+ /**
1099
+ * Check if a session exists and mark it as removed
1100
+ * @returns The session record if it existed, undefined otherwise
1101
+ */
1102
+ async checkAndRemoveSession(sessionId) {
1103
+ this.logger.debug(() => ({ data: {
1104
+ action: "checking and removing",
1105
+ sessionId
1106
+ } }));
1107
+ const filter = {
1108
+ sessionId,
1109
+ deleted: { $exists: false }
1110
+ };
1111
+ try {
1112
+ return await this.tables.session.findOneAndUpdate(filter, {
1113
+ deleted: true,
1114
+ lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
1115
+ pendingStage: true
1116
+ }).lean() || void 0;
1117
+ } catch (err) {
1118
+ throw new _prosopo_common.ProsopoDBError("DATABASE.SESSION_CHECK_REMOVE_FAILED", {
1119
+ context: {
1120
+ error: err,
1121
+ sessionId
1122
+ },
1123
+ logger: this.logger
1124
+ });
1125
+ }
1126
+ }
1127
+ /**
1128
+ * Update a session record by sessionId. Pure Mongo write — callers in
1129
+ * the provider package are responsible for refreshing the Redis cache
1130
+ * via `RedisWriteQueue.patchCachedSession`, matching the existing
1131
+ * caller-side caching pattern (see `frictionlessTasks.createSession`).
1132
+ */
1133
+ async updateSessionRecord(sessionId, updates, streamToCentral) {
1134
+ try {
1135
+ await this.tables.session.updateOne({ sessionId }, { $set: {
1136
+ ...updates,
1137
+ lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
1138
+ pendingStage: true
1139
+ } });
1140
+ if (streamToCentral && this.centralStreamer) {
1141
+ const streamer = this.centralStreamer;
1142
+ const markStored = (ts) => this.tables.session.updateOne({
1143
+ sessionId,
1144
+ lastUpdatedTimestamp: { $lte: ts }
1145
+ }, {
1146
+ $set: { storedAtTimestamp: ts },
1147
+ $unset: { pendingStage: 1 }
1148
+ }).then(() => {});
1149
+ this.tables.session.findOne({ sessionId }).lean().then((record) => {
1150
+ if (record) streamer.streamSessionRecord(record, markStored);
1151
+ }).catch(() => {});
1152
+ }
1153
+ } catch (err) {
1154
+ throw new _prosopo_common.ProsopoDBError("DATABASE.SESSION_GET_FAILED", {
1155
+ context: {
1156
+ error: err,
1157
+ sessionId
1158
+ },
1159
+ logger: this.logger
1160
+ });
1161
+ }
1162
+ }
1163
+ /**
1164
+ * Atomically record SIMD CPU fingerprint readings on the session — only
1165
+ * if they aren't already present. Uses a Mongo aggregation-pipeline
1166
+ * update so `simdReadings` and `simdReadingsStage` are set together,
1167
+ * first-hop-wins. Pure Mongo write — cache refresh is the caller's
1168
+ * responsibility via `RedisWriteQueue.patchCachedSimdReadingsIfAbsent`.
1169
+ */
1170
+ async recordSessionSimdReadingsIfAbsent(sessionId, readings, stage) {
1171
+ try {
1172
+ await this.tables.session.updateOne({ sessionId }, [{ $set: {
1173
+ simdReadings: { $ifNull: ["$simdReadings", readings] },
1174
+ simdReadingsStage: { $ifNull: ["$simdReadingsStage", stage] },
1175
+ lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
1176
+ pendingStage: true
1177
+ } }]);
1178
+ } catch (err) {
1179
+ throw new _prosopo_common.ProsopoDBError("DATABASE.SESSION_GET_FAILED", {
1180
+ context: {
1181
+ error: err,
1182
+ sessionId,
1183
+ stage
1184
+ },
1185
+ logger: this.logger
1186
+ });
1187
+ }
1188
+ }
1189
+ /**
1190
+ * Merge a DNS sidecar observation into the session's `dnsEvent` subdoc
1191
+ * using dotted-path `$set` so DNS-leg and HTTP-leg events on the same
1192
+ * session don't clobber each other. Replaces the prior read-modify-write
1193
+ * pattern that lost half the fields under any race (and 100% of the time
1194
+ * when the read projection didn't include `dnsEvent`).
1195
+ *
1196
+ * `receivedAtIfAbsent` is written only on the first hit for a session
1197
+ * via a `$cond` in an aggregation-pipeline update; later events leave
1198
+ * the original timestamp alone.
1199
+ */
1200
+ async mergeSessionDnsEvent(sessionId, fields, receivedAtIfAbsent) {
1201
+ const setStage = {
1202
+ "dnsEvent.receivedAt": { $ifNull: ["$dnsEvent.receivedAt", receivedAtIfAbsent] },
1203
+ lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
1204
+ pendingStage: true
1205
+ };
1206
+ if (fields.resolverIp !== void 0) setStage["dnsEvent.resolverIp"] = fields.resolverIp;
1207
+ if (fields.peerIp !== void 0) setStage["dnsEvent.peerIp"] = fields.peerIp;
1208
+ if (fields.pathValid !== void 0) setStage["dnsEvent.pathValid"] = fields.pathValid;
1209
+ try {
1210
+ return (await this.tables.session.updateOne({ sessionId }, [{ $set: setStage }])).matchedCount > 0;
1211
+ } catch (err) {
1212
+ throw new _prosopo_common.ProsopoDBError("DATABASE.SESSION_GET_FAILED", {
1213
+ context: {
1214
+ error: err,
1215
+ sessionId
1216
+ },
1217
+ logger: this.logger
1218
+ });
1219
+ }
1220
+ }
1221
+ /**
1222
+ * Get an active session by user IP hash
1223
+ * @param userSitekeyIpHash The hash of user, IP and sitekey combination
1224
+ * @returns The session record if it exists and is not deleted, undefined otherwise
1225
+ */
1226
+ async getSessionByuserSitekeyIpHash(userSitekeyIpHash) {
1227
+ this.logger.debug(() => ({ data: {
1228
+ action: "getting session by user IP hash",
1229
+ userSitekeyIpHash
1230
+ } }));
1231
+ const filter = {
1232
+ userSitekeyIpHash,
1233
+ deleted: { $exists: false }
1234
+ };
1235
+ try {
1236
+ return await this.tables.session.findOne(filter).lean() || void 0;
1237
+ } catch (err) {
1238
+ throw new _prosopo_common.ProsopoDBError("DATABASE.SESSION_GET_FAILED", {
1239
+ context: {
1240
+ error: err,
1241
+ userSitekeyIpHash
1242
+ },
1243
+ logger: this.logger
1244
+ });
1245
+ }
1246
+ }
1247
+ /** Get unstored session records
1248
+ * @description Get session records that have not been stored yet.
1249
+ *
1250
+ * Served by the `pendingStage_partial` index — see
1251
+ * `getUnstoredDappUserCommitments` for the lifecycle of the flag.
1252
+ * `checkAndRemoveSession` also flips the flag so consumed sessions
1253
+ * propagate to the central DB via the next sweep.
1254
+ * @param limit
1255
+ * @param skip
1256
+ */
1257
+ getUnstoredSessionRecords(limit = 1e3, skip = 0) {
1258
+ return Promise.resolve(this.tables?.session).then((tbl) => tbl?.find({ pendingStage: true }).sort({ _id: 1 }).skip(skip).limit(limit).lean()).then((docs) => docs || []);
1259
+ }
1260
+ /** Mark a list of session records as stored.
1261
+ *
1262
+ * `asOfTimestamp` defaults to "now" but the sweep should pass the time
1263
+ * at which it fetched the batch. See markDappUserCommitmentsStored for
1264
+ * the guard rationale.
1265
+ */
1266
+ async markSessionRecordsStored(sessionIds, asOfTimestamp = /* @__PURE__ */ new Date()) {
1267
+ await this.tables?.session.updateMany({
1268
+ sessionId: { $in: sessionIds },
1269
+ lastUpdatedTimestamp: { $lte: asOfTimestamp }
1270
+ }, {
1271
+ $set: { storedAtTimestamp: /* @__PURE__ */ new Date() },
1272
+ $unset: { pendingStage: 1 }
1273
+ }, { upsert: false });
1274
+ }
1275
+ /**
1276
+ * @description Store a Dapp User's pending record
1277
+ */
1278
+ async storePendingImageCommitment(userAccount, requestHash, salt, deadlineTimestamp, requestedAtTimestamp, ipAddress, threshold, sessionId, ipInfo) {
1279
+ if (!(0, _polkadot_util_is.isHex)(requestHash)) throw new _prosopo_common.ProsopoDBError("DATABASE.INVALID_HASH", { context: {
1280
+ failedFuncName: this.storePendingImageCommitment.name,
1281
+ requestHash
1282
+ } });
1283
+ const pendingRecord = {
1284
+ userAccount,
1285
+ pending: true,
1286
+ salt,
1287
+ requestHash,
1288
+ deadlineTimestamp,
1289
+ requestedAtTimestamp,
1290
+ ipAddress,
1291
+ sessionId,
1292
+ threshold,
1293
+ ipInfo,
1294
+ dappAccount: "",
1295
+ providerAccount: "",
1296
+ datasetId: "",
1297
+ id: "",
1298
+ result: { status: _prosopo_types.CaptchaStatus.pending },
1299
+ headers: {},
1300
+ ja4: "",
1301
+ userSignature: "",
1302
+ userSubmitted: false,
1303
+ serverChecked: false
1304
+ };
1305
+ await this.tables?.commitment.updateOne({ requestHash }, { $set: pendingRecord }, { upsert: true });
1306
+ }
1307
+ /**
1308
+ * @description Get a user's pending record
1309
+ */
1310
+ async getPendingImageCommitment(requestHash) {
1311
+ if (!(0, _polkadot_util_is.isHex)(requestHash)) throw new _prosopo_common.ProsopoDBError("DATABASE.INVALID_HASH", { context: {
1312
+ failedFuncName: this.getPendingImageCommitment.name,
1313
+ requestHash
1314
+ } });
1315
+ const doc = await this.tables?.commitment.findOne({
1316
+ requestHash,
1317
+ pending: true
1318
+ }).lean();
1319
+ if (doc) return {
1320
+ dappAccount: doc.dappAccount,
1321
+ pending: doc.pending,
1322
+ salt: doc.salt,
1323
+ requestHash: doc.requestHash,
1324
+ deadlineTimestamp: doc.deadlineTimestamp,
1325
+ requestedAtTimestamp: doc.requestedAtTimestamp,
1326
+ ipAddress: doc.ipAddress,
1327
+ sessionId: doc.sessionId,
1328
+ threshold: doc.threshold
1329
+ };
1330
+ throw new _prosopo_common.ProsopoDBError("DATABASE.PENDING_RECORD_NOT_FOUND", { context: {
1331
+ failedFuncName: this.getPendingImageCommitment.name,
1332
+ requestHash
1333
+ } });
1334
+ }
1335
+ /**
1336
+ * @description Mark a pending request as used
1337
+ */
1338
+ async updatePendingImageCommitmentStatus(requestHash) {
1339
+ if (!(0, _polkadot_util_is.isHex)(requestHash)) throw new _prosopo_common.ProsopoDBError("DATABASE.INVALID_HASH", { context: {
1340
+ failedFuncName: this.updatePendingImageCommitmentStatus.name,
1341
+ requestHash
1342
+ } });
1343
+ await this.tables?.commitment.updateOne({ requestHash }, { $set: { pending: false } });
1344
+ }
1345
+ /**
1346
+ * @description Get all unsolved captchas
1347
+ */
1348
+ async getAllCaptchasByDatasetId(datasetId, state) {
1349
+ const filter = {
1350
+ datasetId,
1351
+ solved: state === _prosopo_types.CaptchaStates.Solved
1352
+ };
1353
+ const docs = await this.tables?.captcha.find(filter).lean();
1354
+ if (docs) return docs.map(({ _id, ...keepAttrs }) => keepAttrs);
1355
+ throw new _prosopo_common.ProsopoDBError("DATABASE.CAPTCHA_GET_FAILED");
1356
+ }
1357
+ /**
1358
+ * @description Get all dapp user solutions by captchaIds
1359
+ */
1360
+ async getAllDappUserSolutions(captchaId) {
1361
+ const filter = { captchaId: { $in: captchaId } };
1362
+ const docs = await this.tables?.usersolution?.find(filter).lean();
1363
+ if (docs) return docs.map(({ _id, ...keepAttrs }) => keepAttrs);
1364
+ throw new _prosopo_common.ProsopoDBError("DATABASE.SOLUTION_GET_FAILED");
1365
+ }
1366
+ async getDatasetIdWithSolvedCaptchasOfSizeN(solvedCaptchaCount) {
1367
+ const docs = await this.tables?.solution.aggregate([
1368
+ { $match: {} },
1369
+ { $group: {
1370
+ _id: "$datasetId",
1371
+ count: { $sum: 1 }
1372
+ } },
1373
+ { $match: { count: { $gte: solvedCaptchaCount } } },
1374
+ { $sample: { size: 1 } }
1375
+ ]);
1376
+ if (docs?.length) return docs[0]._id;
1377
+ throw new _prosopo_common.ProsopoDBError("DATABASE.DATASET_WITH_SOLUTIONS_GET_FAILED");
1378
+ }
1379
+ async getRandomSolvedCaptchasFromSingleDataset(datasetId, size) {
1380
+ if (!(0, _polkadot_util_is.isHex)(datasetId)) throw new _prosopo_common.ProsopoDBError("DATABASE.INVALID_HASH", { context: {
1381
+ failedFuncName: this.getRandomSolvedCaptchasFromSingleDataset.name,
1382
+ datasetId
1383
+ } });
1384
+ const sampleSize = size ? Math.abs(Math.trunc(size)) : 1;
1385
+ const docs = await this.tables?.solution.aggregate([
1386
+ { $match: { datasetId } },
1387
+ { $sample: { size: sampleSize } },
1388
+ { $project: {
1389
+ captchaId: 1,
1390
+ captchaContentId: 1,
1391
+ solution: 1
1392
+ } }
1393
+ ]);
1394
+ if (docs?.length) return docs;
1395
+ throw new _prosopo_common.ProsopoDBError("DATABASE.SOLUTION_GET_FAILED", { context: {
1396
+ failedFuncName: this.getRandomSolvedCaptchasFromSingleDataset.name,
1397
+ datasetId,
1398
+ size
1399
+ } });
1400
+ }
1401
+ /**
1402
+ * @description Get dapp user solution by ID
1403
+ * @param {string[]} commitmentId
1404
+ */
1405
+ async getDappUserSolutionById(commitmentId) {
1406
+ const filter = { commitmentId };
1407
+ const doc = await this.tables?.usersolution?.findOne(filter, { _id: 0 }).lean();
1408
+ if (doc) return doc;
1409
+ throw new _prosopo_common.ProsopoDBError("DATABASE.SOLUTION_GET_FAILED", { context: {
1410
+ failedFuncName: this.getCaptchaById.name,
1411
+ commitmentId
1412
+ } });
1413
+ }
1414
+ /**
1415
+ * @description Get dapp user commitment by user account
1416
+ * @param commitmentId
1417
+ */
1418
+ async getDappUserCommitmentById(commitmentId) {
1419
+ const filter = { id: commitmentId };
1420
+ const doc = await this.tables?.commitment?.findOne(filter, {
1421
+ id: 1,
1422
+ result: 1,
1423
+ serverChecked: 1,
1424
+ requestedAtTimestamp: 1,
1425
+ submittedAtTimestamp: 1,
1426
+ verifiedAtTimestamp: 1,
1427
+ failedAtTimestamp: 1,
1428
+ ipAddress: 1,
1429
+ sessionId: 1,
1430
+ userAccount: 1,
1431
+ dappAccount: 1,
1432
+ headers: 1,
1433
+ ipInfo: 1
1434
+ }).lean();
1435
+ return doc ? doc : void 0;
1436
+ }
1437
+ /**
1438
+ * @description Get dapp user commitment by user account
1439
+ * @param {string} userAccount
1440
+ * @param {string} dappAccount
1441
+ */
1442
+ async getDappUserCommitmentByAccount(userAccount, dappAccount) {
1443
+ const filter = {
1444
+ userAccount,
1445
+ dappAccount
1446
+ };
1447
+ const docs = await this.tables?.commitment?.find(filter, {
1448
+ _id: 0,
1449
+ result: 1
1450
+ }, { sort: { _id: -1 } }).lean();
1451
+ return docs ? docs : [];
1452
+ }
1453
+ /**
1454
+ * @description Approve a dapp user's solution
1455
+ * @param {string[]} commitmentId
1456
+ * @param coords
1457
+ */
1458
+ async approveDappUserCommitment(commitmentId, coords) {
1459
+ try {
1460
+ const updateDoc = {
1461
+ result: { status: _prosopo_types.CaptchaStatus.approved },
1462
+ lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
1463
+ pendingStage: true,
1464
+ ...coords ? { coords } : {}
1465
+ };
1466
+ const filter = { id: commitmentId };
1467
+ await this.tables?.commitment?.findOneAndUpdate(filter, { $set: updateDoc }, { upsert: false }).lean();
1468
+ this.centralStreamer?.streamImageUpdate(() => this.tables.commitment.findOne({ id: commitmentId }).lean(), (ts) => this.tables.commitment.updateOne({
1469
+ id: commitmentId,
1470
+ lastUpdatedTimestamp: { $lte: ts }
1471
+ }, {
1472
+ $set: { storedAtTimestamp: ts },
1473
+ $unset: { pendingStage: 1 }
1474
+ }).then(() => {}));
1475
+ } catch (err) {
1476
+ throw new _prosopo_common.ProsopoDBError("DATABASE.SOLUTION_APPROVE_FAILED", { context: {
1477
+ error: err,
1478
+ commitmentId
1479
+ } });
1480
+ }
1481
+ }
1482
+ /**
1483
+ * @description Disapprove a dapp user's solution
1484
+ * @param {string} commitmentId
1485
+ * @param coords
1486
+ * @param reason
1487
+ */
1488
+ async disapproveDappUserCommitment(commitmentId, reason, coords) {
1489
+ try {
1490
+ const updateDoc = {
1491
+ result: {
1492
+ status: _prosopo_types.CaptchaStatus.disapproved,
1493
+ reason
1494
+ },
1495
+ lastUpdatedTimestamp: /* @__PURE__ */ new Date(),
1496
+ pendingStage: true,
1497
+ ...coords ? { coords } : {}
1498
+ };
1499
+ const filter = { id: commitmentId };
1500
+ await this.tables?.commitment?.findOneAndUpdate(filter, { $set: updateDoc }, { upsert: false }).lean();
1501
+ this.centralStreamer?.streamImageUpdate(() => this.tables.commitment.findOne({ id: commitmentId }).lean(), (ts) => this.tables.commitment.updateOne({
1502
+ id: commitmentId,
1503
+ lastUpdatedTimestamp: { $lte: ts }
1504
+ }, {
1505
+ $set: { storedAtTimestamp: ts },
1506
+ $unset: { pendingStage: 1 }
1507
+ }).then(() => {}));
1508
+ } catch (err) {
1509
+ throw new _prosopo_common.ProsopoDBError("DATABASE.SOLUTION_APPROVE_FAILED", { context: {
1510
+ error: err,
1511
+ commitmentId
1512
+ } });
1513
+ }
1514
+ }
1515
+ /**
1516
+ * @description Flag a dapp user's solutions as used by calculated solution
1517
+ * @param {string[]} captchaIds
1518
+ */
1519
+ async flagProcessedDappUserSolutions(captchaIds) {
1520
+ try {
1521
+ await this.tables?.usersolution?.updateMany({ captchaId: { $in: captchaIds } }, { $set: { processed: true } }, { upsert: false }).lean();
1522
+ } catch (err) {
1523
+ throw new _prosopo_common.ProsopoDBError("DATABASE.SOLUTION_FLAG_FAILED", { context: {
1524
+ error: err,
1525
+ captchaIds
1526
+ } });
1527
+ }
1528
+ }
1529
+ /**
1530
+ * @description Flag dapp users' commitments as used by calculated solution
1531
+ * @param {string[]} commitmentIds
1532
+ */
1533
+ async flagProcessedDappUserCommitments(commitmentIds) {
1534
+ try {
1535
+ const distinctCommitmentIds = [...new Set(commitmentIds)];
1536
+ await this.tables?.commitment?.updateMany({ id: { $in: distinctCommitmentIds } }, { $set: { processed: true } }, { upsert: false }).lean();
1537
+ } catch (err) {
1538
+ throw new _prosopo_common.ProsopoDBError("DATABASE.COMMITMENT_FLAG_FAILED", { context: {
1539
+ error: err,
1540
+ commitmentIds
1541
+ } });
1542
+ }
1543
+ }
1544
+ /**
1545
+ * @description Get a scheduled task status record by task ID and status
1546
+ */
1547
+ async getScheduledTaskStatus(taskId, status) {
1548
+ const filter = {
1549
+ _id: taskId,
1550
+ status
1551
+ };
1552
+ const cursor = await this.tables?.scheduler?.findOne(filter).lean();
1553
+ return cursor ? cursor : void 0;
1554
+ }
1555
+ /**
1556
+ * @description Get the most recent scheduled task status record for a given task
1557
+ */
1558
+ async getLastScheduledTaskStatus(task, status) {
1559
+ const filter = { processName: task };
1560
+ if (status) filter.status = status;
1561
+ const cursor = await this.tables?.scheduler?.findOne(filter).sort({ datetime: -1 }).limit(1).lean();
1562
+ return cursor ? cursor : void 0;
1563
+ }
1564
+ /**
1565
+ * @description Create the status of a scheduled task
1566
+ */
1567
+ async createScheduledTaskStatus(taskName, status) {
1568
+ const now = /* @__PURE__ */ new Date();
1569
+ const doc = _prosopo_types_database.ScheduledTaskSchema.parse({
1570
+ processName: taskName,
1571
+ datetime: now,
1572
+ status
1573
+ });
1574
+ return (await this.tables?.scheduler.create(doc))._id;
1575
+ }
1576
+ /**
1577
+ * @description Update the status of a scheduled task and an optional result
1578
+ */
1579
+ async updateScheduledTaskStatus(taskId, status, result) {
1580
+ const update = {
1581
+ status,
1582
+ updated: /* @__PURE__ */ new Date(),
1583
+ ...result && { result }
1584
+ };
1585
+ const filter = { _id: taskId };
1586
+ await this.tables?.scheduler.updateOne(filter, { $set: update }, { upsert: false });
1587
+ }
1588
+ /**
1589
+ * @description Clean up the scheduled task status records
1590
+ */
1591
+ async cleanupScheduledTaskStatus(status) {
1592
+ const filter = { status };
1593
+ await this.tables?.scheduler.deleteMany(filter);
1594
+ }
1595
+ /**
1596
+ * @description Update the client records
1597
+ */
1598
+ async updateClientRecords(clientRecords) {
1599
+ const ops = clientRecords.map((record) => {
1600
+ const clientRecord = {
1601
+ account: record.account,
1602
+ settings: record.settings,
1603
+ tier: record.tier
1604
+ };
1605
+ return { updateOne: {
1606
+ filter: { account: record.account },
1607
+ update: { $set: clientRecord },
1608
+ upsert: true
1609
+ } };
1610
+ });
1611
+ if (!ops || ops.length === 0) {
1612
+ await this.logger.debug(() => ({ msg: "No client updates to apply" }));
1613
+ return;
1614
+ }
1615
+ await this.tables?.client.bulkWrite(ops);
1616
+ }
1617
+ /**
1618
+ * @description Remove client records by account
1619
+ */
1620
+ async removeClientRecords(accounts) {
1621
+ if (!accounts || accounts.length === 0) return;
1622
+ await this.tables?.client.deleteMany({ account: { $in: accounts } });
1623
+ }
1624
+ /**
1625
+ * @description Get all client records
1626
+ */
1627
+ async getAllClientRecords() {
1628
+ return await this.tables?.client.find().lean() || [];
1629
+ }
1630
+ /**
1631
+ * @description Get a client record
1632
+ */
1633
+ async getClientRecord(account) {
1634
+ const filter = { account };
1635
+ const doc = await this.tables?.client.findOne(filter, {
1636
+ account: 1,
1637
+ settings: 1,
1638
+ tier: 1
1639
+ }).lean();
1640
+ return doc ? doc : void 0;
1641
+ }
1642
+ /**
1643
+ * @description Store a detector key
1644
+ */
1645
+ async storeDetectorKey(detectorKey) {
1646
+ return this.tables?.detector.create({
1647
+ detectorKey,
1648
+ createdAt: /* @__PURE__ */ new Date()
1649
+ });
1650
+ }
1651
+ /**
1652
+ * @description Remove a detector key
1653
+ * @param detectorKey The detector key to remove
1654
+ * @param expirationInSeconds Optional expiration time in seconds (default is 10 minutes)
1655
+ * */
1656
+ async removeDetectorKey(detectorKey, expirationInSeconds) {
1657
+ const filter = { detectorKey };
1658
+ const expiresAt = new Date(Date.now() + (expirationInSeconds || 600) * 1e3);
1659
+ await this.tables?.detector.updateOne(filter, { $set: { expiresAt } });
1660
+ }
1661
+ /**
1662
+ * @description Get valid detector keys
1663
+ */
1664
+ async getDetectorKeys() {
1665
+ return (await this.tables?.detector.find({ $or: [{ expiresAt: { $exists: false } }, { expiresAt: null }] }, { detectorKey: 1 }).sort({ createdAt: -1 }).lean() || []).map((record) => record.detectorKey);
1666
+ }
1667
+ /**
1668
+ * Stores a decision machine artifact with a unique scope identifier.
1669
+ *
1670
+ * The combination of scope + dappAccount uniquely identifies a single artifact:
1671
+ * - Global scope: One artifact per provider (dappAccount is null)
1672
+ * - Dapp scope: One artifact per dapp account (dappAccount is specified)
1673
+ *
1674
+ * @param artifact - The decision machine artifact to store
1675
+ */
1676
+ async upsertDecisionMachineArtifact(artifact) {
1677
+ const now = /* @__PURE__ */ new Date();
1678
+ const dappAccount = artifact.dappAccount ?? null;
1679
+ const kind = artifact.kind ?? _prosopo_types.DecisionMachineKind.Routing;
1680
+ const filter = {
1681
+ scope: artifact.scope,
1682
+ dappAccount,
1683
+ kind
1684
+ };
1685
+ await this.tables?.decisionMachine.updateOne(filter, {
1686
+ $set: {
1687
+ scope: artifact.scope,
1688
+ dappAccount,
1689
+ kind,
1690
+ runtime: artifact.runtime,
1691
+ language: artifact.language,
1692
+ source: artifact.source,
1693
+ name: artifact.name,
1694
+ version: artifact.version,
1695
+ captchaType: artifact.captchaType,
1696
+ updatedAt: now
1697
+ },
1698
+ $setOnInsert: { createdAt: now }
1699
+ }, { upsert: true });
1700
+ }
1701
+ /**
1702
+ * Retrieves a single decision machine artifact by scope and optional dapp account.
1703
+ *
1704
+ * @param scope - The scope level (Global or Dapp)
1705
+ * @param dappAccount - Required for Dapp scope, unused for Global scope
1706
+ * @returns The matching artifact, or undefined if not found
1707
+ */
1708
+ async getDecisionMachineArtifact(scope, dappAccount, kind) {
1709
+ const filter = {
1710
+ scope,
1711
+ dappAccount: dappAccount ?? null
1712
+ };
1713
+ if (kind !== void 0) filter.kind = kind;
1714
+ return await this.tables?.decisionMachine.findOne(filter).lean() ?? void 0;
1715
+ }
1716
+ /**
1717
+ * Retrieves all decision machine artifacts.
1718
+ *
1719
+ * @returns Array of all decision machine artifacts
1720
+ */
1721
+ async getAllDecisionMachineArtifacts() {
1722
+ return await this.tables?.decisionMachine.find({}).lean() ?? [];
1723
+ }
1724
+ /**
1725
+ * Retrieves a single decision machine artifact by MongoDB ObjectId.
1726
+ *
1727
+ * @param id - The MongoDB ObjectId as a string
1728
+ * @returns The matching artifact, or undefined if not found
1729
+ */
1730
+ async getDecisionMachineArtifactById(id) {
1731
+ return await this.tables?.decisionMachine.findById(id).lean() ?? void 0;
1732
+ }
1733
+ /**
1734
+ * Removes a decision machine artifact by MongoDB ObjectId.
1735
+ *
1736
+ * @param id - The MongoDB ObjectId as a string
1737
+ * @returns true if deleted, false if not found
1738
+ */
1739
+ async removeDecisionMachineArtifact(id) {
1740
+ return ((await this.tables?.decisionMachine.deleteOne({ _id: id }))?.deletedCount ?? 0) > 0;
1741
+ }
1742
+ /**
1743
+ * Removes all decision machine artifacts.
1744
+ *
1745
+ * @returns The number of artifacts deleted
1746
+ */
1747
+ async removeAllDecisionMachineArtifacts() {
1748
+ return (await this.tables?.decisionMachine.deleteMany({}))?.deletedCount ?? 0;
1749
+ }
1750
+ /**
1751
+ * @description set client context-specific entropy
1752
+ */
1753
+ async setClientContextEntropy(account, contextType, entropy) {
1754
+ const filter = {
1755
+ account,
1756
+ contextType
1757
+ };
1758
+ await this.tables?.clientContextEntropy.updateOne(filter, { $set: {
1759
+ account,
1760
+ contextType,
1761
+ entropy
1762
+ } }, { upsert: true });
1763
+ }
1764
+ /**
1765
+ * @description get client context-specific entropy
1766
+ */
1767
+ async getClientContextEntropy(account, contextType) {
1768
+ const filter = {
1769
+ account,
1770
+ contextType
1771
+ };
1772
+ const doc = await this.tables?.clientContextEntropy.findOne(filter).lean();
1773
+ return doc ? doc.entropy : void 0;
1774
+ }
1775
+ /** Sample captcha records from the database for a specific context */
1776
+ async sampleContextEntropy(sampleSize, siteKey, contextType) {
1777
+ const size = sampleSize ? Math.abs(Math.trunc(sampleSize)) : 1;
1778
+ const max = 1e4;
1779
+ if (size > max) throw new _prosopo_common.ProsopoDBError("DATABASE.CAPTCHA_SAMPLE_SIZE_EXCEEDED", { context: {
1780
+ failedFuncName: this.sampleContextEntropy.name,
1781
+ sampleSize
1782
+ } });
1783
+ const pipeline = [
1784
+ { $match: {
1785
+ dappAccount: siteKey,
1786
+ requestedAtTimestamp: { $gt: new Date((/* @__PURE__ */ new Date()).getTime() - TWENTY_FOUR_HOURS_IN_MS) }
1787
+ } },
1788
+ { $sample: { size: max } },
1789
+ { $lookup: {
1790
+ from: "sessions",
1791
+ localField: "sessionId",
1792
+ foreignField: "sessionId",
1793
+ as: "sessionData"
1794
+ } },
1795
+ { $unwind: {
1796
+ path: "$sessionData",
1797
+ preserveNullAndEmptyArrays: false
1798
+ } }
1799
+ ];
1800
+ if (contextType === _prosopo_types.ContextType.Webview) pipeline.push({ $match: { "sessionData.webView": true } });
1801
+ else if (contextType === _prosopo_types.ContextType.Default) pipeline.push({ $match: { "sessionData.webView": false } });
1802
+ pipeline.push({ $sample: { size } }, { $project: {
1803
+ _id: 0,
1804
+ sessionId: 1
1805
+ } });
1806
+ const docs = await this.tables?.powcaptcha.aggregate(pipeline);
1807
+ if (docs?.length === 0) return [];
1808
+ return (await Promise.all(docs.map(async (doc) => {
1809
+ if (doc.sessionId) return (await this.getSessionRecordBySessionId(doc.sessionId))?.decryptedHeadHash;
1810
+ }))).filter((headHash) => headHash !== void 0);
1811
+ }
1812
+ async getSpamEmailDomain(domain) {
1813
+ if (!this.tables?.spamEmailDomain) throw new _prosopo_common.ProsopoDBError("DATABASE.DATABASE_IMPORT_ERROR", { context: { failedFuncName: this.getSpamEmailDomain.name } });
1814
+ const suffixCandidates = (0, _prosopo_util.buildDomainSuffixCandidates)(domain).slice(0, MAX_DOMAIN_SUFFIX_CANDIDATES);
1815
+ const query = suffixCandidates.length > 0 ? { domain: { $in: suffixCandidates } } : { domain };
1816
+ return await this.tables.spamEmailDomain.findOne(query).exec();
1817
+ }
1818
+ async bulkUpdateSpamEmailDomains(domains, upsert) {
1819
+ if (!this.tables?.spamEmailDomain) throw new _prosopo_common.ProsopoDBError("DATABASE.DATABASE_IMPORT_ERROR", { context: { failedFuncName: this.bulkUpdateSpamEmailDomains.name } });
1820
+ const bulkOps = domains.map((op) => ({ updateOne: {
1821
+ filter: op.filter,
1822
+ update: { $set: op.update },
1823
+ upsert
1824
+ } }));
1825
+ if (bulkOps.length > 0) await this.tables.spamEmailDomain.bulkWrite(bulkOps);
1826
+ }
1827
+ };
1828
+ //#endregion
2253
1829
  exports.ProviderDatabase = ProviderDatabase;