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