diff --git a/architecture.mmd b/architecture.mmd index 519aaa82ec..dc2d9fd2a0 100644 --- a/architecture.mmd +++ b/architecture.mmd @@ -67,8 +67,14 @@ graph TB AccessRules["Access Rules Engine
spam / traffic / geo filters"] end + %% Captcha request records (sessions, powcaptchas, imagecaptchas a.k.a. + %% commitments) each carry a `requestType` discriminator + %% (session / powcaptcha / imagecaptcha / puzzlecaptcha) so every record + %% is self-describing ahead of consolidating them into one `requests` + %% collection. Phase 1: discriminator added; physical collections are + %% unchanged. "UserCommitment" record/type renamed to "ImageCaptcha". subgraph Storage["Storage"] - MongoDB[(MongoDB
captchas, commitments,
clients, sessions)] + MongoDB[(MongoDB
captchas, imagecaptchas,
powcaptchas, clients, sessions
+ requestType discriminator)] Redis[(Redis
session cache,
write queue)] Dataset[(Dataset
labelled + unlabelled
captcha images)] end diff --git a/packages/database/src/databases/captcha.ts b/packages/database/src/databases/captcha.ts index 37e2b5669d..900bc20a9b 100644 --- a/packages/database/src/databases/captcha.ts +++ b/packages/database/src/databases/captcha.ts @@ -17,13 +17,13 @@ import { type Logger, getLogger } from "@prosopo/logger"; import { type CaptchaProperties, type ICaptchaDatabase, + type ImageCaptchaRecord, type PoWCaptchaRecord, + StoredImageCaptchaRecordSchema, StoredPoWCaptchaRecordSchema, type StoredSession, StoredSessionRecordSchema, - StoredUserCommitmentRecordSchema, type Tables, - type UserCommitmentRecord, } from "@prosopo/types-database"; import type { RootFilterQuery } from "mongoose"; import { MongoDatabase } from "../base/index.js"; @@ -51,7 +51,7 @@ const CAPTCHA_TABLES = [ { collectionName: TableNames.commitment, modelName: "UserCommitment", - schema: StoredUserCommitmentRecordSchema, + schema: StoredImageCaptchaRecordSchema, }, ]; @@ -126,7 +126,7 @@ export class CaptchaDatabase extends MongoDatabase implements ICaptchaDatabase { async saveCaptchas( sessionEvents: StoredSession[], - imageCaptchaEvents: UserCommitmentRecord[], + imageCaptchaEvents: ImageCaptchaRecord[], powCaptchaEvents: PoWCaptchaRecord[], ) { await this.connect(); @@ -206,7 +206,7 @@ export class CaptchaDatabase extends MongoDatabase implements ICaptchaDatabase { filter: RootFilterQuery = {}, limit = 100, ): Promise<{ - userCommitmentRecords: UserCommitmentRecord[]; + imageCaptchaRecords: ImageCaptchaRecord[]; powCaptchaRecords: PoWCaptchaRecord[]; }> { await this.connect(); @@ -215,7 +215,7 @@ export class CaptchaDatabase extends MongoDatabase implements ICaptchaDatabase { const commitmentResults = await this.tables.commitment .find(filter) .limit(limit) - .lean(); + .lean(); const powCaptchaResults = await this.tables.powcaptcha .find(filter) @@ -223,7 +223,7 @@ export class CaptchaDatabase extends MongoDatabase implements ICaptchaDatabase { .lean(); return { - userCommitmentRecords: commitmentResults, + imageCaptchaRecords: commitmentResults, powCaptchaRecords: powCaptchaResults, }; } catch (error) { diff --git a/packages/database/src/databases/centralDbStreamer.ts b/packages/database/src/databases/centralDbStreamer.ts index a764d8237d..5f70c03083 100644 --- a/packages/database/src/databases/centralDbStreamer.ts +++ b/packages/database/src/databases/centralDbStreamer.ts @@ -14,9 +14,9 @@ import { type Logger, getLogger } from "@prosopo/logger"; import type { + ImageCaptchaRecord, PoWCaptchaRecord, StoredSession, - UserCommitmentRecord, } from "@prosopo/types-database"; import { CaptchaDatabase } from "./captcha.js"; @@ -154,7 +154,7 @@ export class CentralDbStreamer { * Fire-and-forget: errors are logged, never thrown. */ streamImageRecord( - record: UserCommitmentRecord, + record: ImageCaptchaRecord, markStored?: MarkStoredCallback, ): void { const timestamp = this.getRecordTimestamp(record); @@ -180,7 +180,7 @@ export class CentralDbStreamer { * Stream an image captcha update by fetching the full record first. */ streamImageUpdate( - getFullRecord: () => Promise, + getFullRecord: () => Promise, markStored?: MarkStoredCallback, ): void { getFullRecord() diff --git a/packages/database/src/databases/provider.ts b/packages/database/src/databases/provider.ts index ed9459183e..5701d88725 100644 --- a/packages/database/src/databases/provider.ts +++ b/packages/database/src/databases/provider.ts @@ -43,6 +43,7 @@ import { type PoWChallengeId, type PuzzleCaptchaStored, type RequestHeaders, + RequestType, type ResultReason, type ScheduledTaskNames, type ScheduledTaskResult, @@ -124,6 +125,12 @@ const PROVIDER_TABLES = [ }, { collectionName: TableNames.powcaptcha, + // NOTE: model name is "PowCaptcha" (lowercase w). The private-DB reader + // (@prosopo/database-private PoWCaptchaData) registers "PoWCaptcha" + // (capital W); both pluralize to the same physical `powcaptchas` + // collection, so they coexist. This inconsistency is resolved when the + // records are merged into the unified `requests` collection — do not + // "fix" either string in isolation, as changing it moves the collection. modelName: "PowCaptcha", schema: PoWCaptchaRecordSchema, }, @@ -669,7 +676,7 @@ export class ProviderDatabase /** * @description Store a Dapp User's captcha solution commitment */ - async storeUserImageCaptchaSolution( + async storeImageCaptchaSolution( captchas: CaptchaSolution[], commit: UserCommitment, ): Promise { @@ -1239,7 +1246,7 @@ export class ProviderDatabase /** @description Get serverChecked Dapp User image captcha commitments from the commitments table */ - async getCheckedDappUserCommitments(): Promise { + async getCheckedImageCaptchas(): Promise { const filter: { [key in keyof Pick]: boolean; } = { [StoredStatusNames.serverChecked]: true }; @@ -1254,13 +1261,13 @@ export class ProviderDatabase * * Served by the `pendingStage_partial` index. Records have * `pendingStage: true` set on insert and on every mutation (see - * `updateDappUserCommitment`, `markDappUserCommitmentsChecked`, - * `approveDappUserCommitment`, `disapproveDappUserCommitment`, - * `storePendingImageCommitment`). `markDappUserCommitmentsStored` clears + * `updateImageCaptcha`, `markImageCaptchasChecked`, + * `approveImageCaptcha`, `disapproveImageCaptcha`, + * `storePendingImageCommitment`). `markImageCaptchasStored` clears * the flag after a successful stage, guarded by `lastUpdatedTimestamp` * so an in-flight update isn't lost. */ - async getUnstoredDappUserCommitments( + async getUnstoredImageCaptchas( limit = 1000, skip = 0, ): Promise { @@ -1281,7 +1288,7 @@ export class ProviderDatabase * fetch and mark-stored — those records will leave pendingStage set so * the next sweep picks them up. */ - async markDappUserCommitmentsStored( + async markImageCaptchasStored( commitmentIds: Hash[], asOfTimestamp: Date = new Date(), ): Promise { @@ -1300,7 +1307,7 @@ export class ProviderDatabase /** @description Mark a list of captcha commits as checked */ - async markDappUserCommitmentsChecked(commitmentIds: Hash[]): Promise { + async markImageCaptchasChecked(commitmentIds: Hash[]): Promise { const updateDoc: Pick< StoredCaptcha, "serverChecked" | "lastUpdatedTimestamp" | "pendingStage" @@ -1319,7 +1326,7 @@ export class ProviderDatabase /** @description Update an image captcha commitment */ - async updateDappUserCommitment( + async updateImageCaptcha( commitmentId: Hash, updates: Partial, ) { @@ -1342,7 +1349,7 @@ export class ProviderDatabase skip = 0, ): Promise { // Served by the `pendingStage_partial` index — see - // `getUnstoredDappUserCommitments` for the lifecycle of the flag. + // `getUnstoredImageCaptchas` for the lifecycle of the flag. const docs = await this.tables?.powcaptcha .find({ pendingStage: true }) .sort({ _id: 1 }) @@ -1355,7 +1362,7 @@ export class ProviderDatabase /** @description Mark a list of PoW captcha commits as stored. * * `asOfTimestamp` defaults to "now" but the sweep should pass the time - * at which it fetched the batch. See markDappUserCommitmentsStored for + * at which it fetched the batch. See markImageCaptchasStored for * the guard rationale. */ async markDappUserPoWCommitmentsStored( @@ -1623,7 +1630,7 @@ export class ProviderDatabase * @description Get session records that have not been stored yet. * * Served by the `pendingStage_partial` index — see - * `getUnstoredDappUserCommitments` for the lifecycle of the flag. + * `getUnstoredImageCaptchas` for the lifecycle of the flag. * `checkAndRemoveSession` also flips the flag so consumed sessions * propagate to the central DB via the next sweep. * @param limit @@ -1645,7 +1652,7 @@ export class ProviderDatabase /** Mark a list of session records as stored. * * `asOfTimestamp` defaults to "now" but the sweep should pass the time - * at which it fetched the batch. See markDappUserCommitmentsStored for + * at which it fetched the batch. See markImageCaptchasStored for * the guard rationale. */ async markSessionRecordsStored( @@ -1688,6 +1695,10 @@ export class ProviderDatabase }); } const pendingRecord = { + // Stamp the discriminator explicitly: this placeholder is built by + // hand and written via `$set`, so we don't rely on the schema + // default applying on upsert-insert. + requestType: RequestType.imagecaptcha, userAccount, pending: true, salt, @@ -1701,7 +1712,7 @@ export class ProviderDatabase // Deliberately NOT setting pendingStage here. Placeholder // records have id: "" until the user submits a solution; if we // flag them, the sweep picks them up via the partial index but - // then `markDappUserCommitmentsStored` runs + // then `markImageCaptchasStored` runs // `{ id: { $in: ["", ...] } }` which collapses to a single "" // bound and scans every empty-id row via the `id_-1` index — // turning a cheap sweep into a fresh cache evictor. The real @@ -1943,7 +1954,7 @@ export class ProviderDatabase * @description Get dapp user commitment by user account * @param commitmentId */ - async getDappUserCommitmentById( + async getImageCaptchaById( commitmentId: string, ): Promise { const filter: Pick = { id: commitmentId }; @@ -1972,7 +1983,7 @@ export class ProviderDatabase * @param {string} userAccount * @param {string} dappAccount */ - async getDappUserCommitmentByAccount( + async getImageCaptchaByAccount( userAccount: string, dappAccount: string, ): Promise { @@ -1999,7 +2010,7 @@ export class ProviderDatabase * @param {string[]} commitmentId * @param coords */ - async approveDappUserCommitment( + async approveImageCaptcha( commitmentId: string, coords?: [number, number][][], ): Promise { @@ -2047,7 +2058,7 @@ export class ProviderDatabase * @param coords * @param reason */ - async disapproveDappUserCommitment( + async disapproveImageCaptcha( commitmentId: string, reason?: ResultReason, coords?: [number, number][][], @@ -2114,7 +2125,7 @@ export class ProviderDatabase * @description Flag dapp users' commitments as used by calculated solution * @param {string[]} commitmentIds */ - async flagProcessedDappUserCommitments(commitmentIds: Hash[]): Promise { + async flagProcessedImageCaptchas(commitmentIds: Hash[]): Promise { try { const distinctCommitmentIds = [...new Set(commitmentIds)]; await this.tables?.commitment diff --git a/packages/database/src/tests/unit/requestType.unit.test.ts b/packages/database/src/tests/unit/requestType.unit.test.ts new file mode 100644 index 0000000000..2d08cc2d5d --- /dev/null +++ b/packages/database/src/tests/unit/requestType.unit.test.ts @@ -0,0 +1,115 @@ +// Copyright 2021-2026 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + CaptchaStatus, + ImageCaptchaSchema, + IpAddressType, + PoWCaptchaStoredSchema, + RequestType, + RequestTypeSchema, + SessionSchema, + UserCommitmentSchema, +} from "@prosopo/types"; +import { + ImageCaptchaRecordSchema, + PoWCaptchaRecordSchema, + PuzzleCaptchaRecordSchema, + SessionRecordSchema, + StoredImageCaptchaRecordSchema, + StoredUserCommitmentRecordSchema, + UserCommitmentRecordSchema, +} from "@prosopo/types-database"; +import mongoose, { type Schema } from "mongoose"; +import { describe, expect, it } from "vitest"; + +describe("RequestType discriminator", () => { + it("RequestTypeSchema accepts every RequestType value", () => { + for (const value of Object.values(RequestType)) { + expect(RequestTypeSchema.parse(value)).toBe(value); + } + }); + + it("RequestTypeSchema rejects unknown request types", () => { + expect(() => RequestTypeSchema.parse("not-a-request-type")).toThrow(); + }); + + it("Mongoose record schemas stamp the correct requestType default on new docs", () => { + const cases: Array<[string, Schema, RequestType]> = [ + ["ReqTypeTestSession", SessionRecordSchema, RequestType.session], + ["ReqTypeTestPow", PoWCaptchaRecordSchema, RequestType.powcaptcha], + ["ReqTypeTestImage", ImageCaptchaRecordSchema, RequestType.imagecaptcha], + ["ReqTypeTestPuzzle", PuzzleCaptchaRecordSchema, RequestType.puzzlecaptcha], + ]; + for (const [modelName, schema, expected] of cases) { + expect(schema.path("requestType").instance).toBe("String"); + // A throwaway model (no DB connection needed) applies schema defaults + // at construction time — the same path production writes take. + const model = + mongoose.models[modelName] ?? mongoose.model(modelName, schema); + const doc = new model(); + expect(doc.get("requestType")).toBe(expected); + } + }); + + it("Zod record schemas expose requestType as optional (back-compat)", () => { + for (const schema of [ + ImageCaptchaSchema, + PoWCaptchaStoredSchema, + SessionSchema, + ]) { + expect(schema.shape.requestType.isOptional()).toBe(true); + } + }); + + it("ImageCaptchaSchema parses records with and without requestType", () => { + const base = { + userAccount: "u", + dappAccount: "d", + datasetId: "ds", + providerAccount: "p", + id: "id", + result: { status: CaptchaStatus.approved }, + userSignature: "", + ipAddress: { lower: 1n, type: IpAddressType.v4 }, + headers: {}, + ja4: "", + userSubmitted: true, + serverChecked: true, + requestedAtTimestamp: new Date(), + pending: false, + salt: "0x0", + requestHash: "0x0", + deadlineTimestamp: new Date(), + threshold: 0.8, + }; + expect(() => ImageCaptchaSchema.parse(base)).not.toThrow(); + expect(() => + ImageCaptchaSchema.parse({ + ...base, + requestType: RequestType.imagecaptcha, + }), + ).not.toThrow(); + }); +}); + +describe("UserCommitment -> ImageCaptcha back-compat aliases", () => { + it("deprecated schema aliases are identity-equal to the new schemas", () => { + expect(UserCommitmentSchema).toBe(ImageCaptchaSchema); + expect(UserCommitmentRecordSchema).toBe(ImageCaptchaRecordSchema); + expect(StoredUserCommitmentRecordSchema).toBe( + StoredImageCaptchaRecordSchema, + ); + }); +}); diff --git a/packages/provider/src/tasks/client/clientTasks.ts b/packages/provider/src/tasks/client/clientTasks.ts index 66e1dec737..57eb3d2f51 100644 --- a/packages/provider/src/tasks/client/clientTasks.ts +++ b/packages/provider/src/tasks/client/clientTasks.ts @@ -120,7 +120,7 @@ export class ClientTaskManager { await this.processBatchesWithCursor( async (skip: number) => - await this.providerDB.getUnstoredDappUserCommitments( + await this.providerDB.getUnstoredImageCaptchas( BATCH_SIZE, skip, ), @@ -134,14 +134,14 @@ export class ClientTaskManager { // inserts with `id: ""` until the user submits a solution. // Defense in depth: even if a stray placeholder slips into // the partial index, never pass `id: ""` to - // markDappUserCommitmentsStored — Mongo collapses + // markImageCaptchasStored — Mongo collapses // `{ id: { $in: ["", "", ...] } }` to a single empty-string // bound and the IXSCAN on `id_-1` then walks every // empty-id document on the node (~100K rows). if (filteredBatch.length > 0) { await captchaDB.saveCaptchas([], filteredBatch, []); - await this.providerDB.markDappUserCommitmentsStored( + await this.providerDB.markImageCaptchasStored( filteredBatch.map((commitment) => commitment.id), sweepStartedAt, ); diff --git a/packages/provider/src/tasks/imgCaptcha/imgCaptchaTasks.ts b/packages/provider/src/tasks/imgCaptcha/imgCaptchaTasks.ts index fcb8a3d2e0..bf99e47b13 100644 --- a/packages/provider/src/tasks/imgCaptcha/imgCaptchaTasks.ts +++ b/packages/provider/src/tasks/imgCaptcha/imgCaptchaTasks.ts @@ -399,7 +399,7 @@ export class ImgCaptchaManager extends CaptchaManager { clientMetaData: { hp: clientMetaData.hp }, }), }; - await this.db.storeUserImageCaptchaSolution(receivedCaptchas, commit); + await this.db.storeImageCaptchaSolution(receivedCaptchas, commit); const solutionRecords = await Promise.all( storedCaptchas.map(async (captcha) => { @@ -420,7 +420,7 @@ export class ImgCaptchaManager extends CaptchaManager { if (containsIdenticalPairs(pairs) && process.env.NODE_ENV !== "test") { // Write commitment disapproval and session update in parallel const writePromises: Promise[] = [ - this.db.disapproveDappUserCommitment( + this.db.disapproveImageCaptcha( commitmentId, "CAPTCHA.INVALID_SOLUTION", pairs, @@ -466,7 +466,7 @@ export class ImgCaptchaManager extends CaptchaManager { }; // Write commitment approval and session update in parallel const writePromises: Promise[] = [ - this.db.approveDappUserCommitment(commitmentId, pairs), + this.db.approveImageCaptcha(commitmentId, pairs), ]; if (pendingRecord.sessionId) { writePromises.push( @@ -481,7 +481,7 @@ export class ImgCaptchaManager extends CaptchaManager { } else { // Write commitment disapproval and session update in parallel const writePromises: Promise[] = [ - this.db.disapproveDappUserCommitment( + this.db.disapproveImageCaptcha( commitmentId, "CAPTCHA.INVALID_SOLUTION", pairs, @@ -599,15 +599,15 @@ export class ImgCaptchaManager extends CaptchaManager { /* * Get dapp user solution from database */ - async getDappUserCommitmentById( + async getImageCaptchaById( commitmentId: string, ): Promise { const dappUserSolution = - await this.db.getDappUserCommitmentById(commitmentId); + await this.db.getImageCaptchaById(commitmentId); if (!dappUserSolution) { throw new ProsopoEnvError("CAPTCHA.DAPP_USER_SOLUTION_NOT_FOUND", { context: { - failedFuncName: this.getDappUserCommitmentById.name, + failedFuncName: this.getImageCaptchaById.name, commitmentId: commitmentId, }, }); @@ -616,11 +616,11 @@ export class ImgCaptchaManager extends CaptchaManager { } /* Check if dapp user has verified solution in cache */ - async getDappUserCommitmentByAccount( + async getImageCaptchaByAccount( userAccount: string, dappAccount: string, ): Promise { - const dappUserSolutions = await this.db.getDappUserCommitmentByAccount( + const dappUserSolutions = await this.db.getImageCaptchaByAccount( userAccount, dappAccount, ); @@ -651,8 +651,8 @@ export class ImgCaptchaManager extends CaptchaManager { storeMetadata = false, ): Promise { const solution = await (commitmentId - ? this.getDappUserCommitmentById(commitmentId) - : this.getDappUserCommitmentByAccount(user, dapp)); + ? this.getImageCaptchaById(commitmentId) + : this.getImageCaptchaByAccount(user, dapp)); // No solution exists if (!solution) { @@ -676,7 +676,7 @@ export class ImgCaptchaManager extends CaptchaManager { }; } - await this.db.markDappUserCommitmentsChecked([solution.id]); + await this.db.markImageCaptchasChecked([solution.id]); // -- END WARNING -- // A solution exists but is disapproved @@ -972,7 +972,7 @@ export class ImgCaptchaManager extends CaptchaManager { } // Batch writes: separate non-streaming updates from streaming result writes. - // - providedIp / metadata use updateDappUserCommitment (no central streaming) + // - providedIp / metadata use updateImageCaptcha (no central streaming) // - approve/disapprove use dedicated methods that trigger centralStreamer const writePromises: Promise[] = []; @@ -986,17 +986,17 @@ export class ImgCaptchaManager extends CaptchaManager { } if (Object.keys(sideUpdates).length > 0) { writePromises.push( - this.db.updateDappUserCommitment(solution.id, sideUpdates), + this.db.updateImageCaptcha(solution.id, sideUpdates), ); } // Write result via the streaming-aware methods if (commitmentId) { if (isApproved) { - writePromises.push(this.db.approveDappUserCommitment(commitmentId)); + writePromises.push(this.db.approveImageCaptcha(commitmentId)); } else if (commitmentUpdates.result) { writePromises.push( - this.db.disapproveDappUserCommitment( + this.db.disapproveImageCaptcha( commitmentId, commitmentUpdates.result.reason, ), diff --git a/packages/provider/src/tests/unit/tasks/client/clientTasks.unit.test.ts b/packages/provider/src/tests/unit/tasks/client/clientTasks.unit.test.ts index aaa0e77370..5ef2590029 100644 --- a/packages/provider/src/tests/unit/tasks/client/clientTasks.unit.test.ts +++ b/packages/provider/src/tests/unit/tasks/client/clientTasks.unit.test.ts @@ -139,8 +139,8 @@ describe("ClientTaskManager", () => { markFrictionlessTokenRecordsStored: vi.fn(), getUnstoredSessionRecords: vi.fn().mockResolvedValue([]), markSessionRecordsStored: vi.fn(), - getUnstoredDappUserCommitments: vi.fn().mockResolvedValue([]), - markDappUserCommitmentsStored: vi.fn(), + getUnstoredImageCaptchas: vi.fn().mockResolvedValue([]), + markImageCaptchasStored: vi.fn(), markDappUserPoWCommitmentsStored: vi.fn(), getUnstoredDappUserPoWCommitments: vi.fn().mockResolvedValue([]), createScheduledTaskStatus: vi.fn( @@ -249,7 +249,7 @@ describe("ClientTaskManager", () => { msg: "Mongo env not set", }); - expect(providerDB.getUnstoredDappUserCommitments).not.toHaveBeenCalled(); + expect(providerDB.getUnstoredImageCaptchas).not.toHaveBeenCalled(); }); it("should store commitments externally if mongoCaptchaUri is set", async () => { @@ -263,7 +263,7 @@ describe("ClientTaskManager", () => { ]; // biome-ignore lint/suspicious/noExplicitAny: TODO fix - (providerDB.getUnstoredDappUserCommitments as any).mockResolvedValueOnce( + (providerDB.getUnstoredImageCaptchas as any).mockResolvedValueOnce( mockCommitments, ); @@ -280,10 +280,10 @@ describe("ClientTaskManager", () => { await clientTaskManager.storeCommitmentsExternal(); - expect(providerDB.getUnstoredDappUserCommitments).toHaveBeenCalled(); + expect(providerDB.getUnstoredImageCaptchas).toHaveBeenCalled(); expect(providerDB.getUnstoredDappUserPoWCommitments).toHaveBeenCalled(); - expect(providerDB.markDappUserCommitmentsStored).toHaveBeenCalledWith( + expect(providerDB.markImageCaptchasStored).toHaveBeenCalledWith( mockCommitments.map((c) => c.id), expect.any(Date), ); @@ -345,7 +345,7 @@ describe("ClientTaskManager", () => { })); // biome-ignore lint/suspicious/noExplicitAny: TODO fix - (providerDB.getUnstoredDappUserCommitments as any).mockResolvedValueOnce( + (providerDB.getUnstoredImageCaptchas as any).mockResolvedValueOnce( mockCommitments, ); // biome-ignore lint/suspicious/noExplicitAny: TODO fix @@ -358,7 +358,7 @@ describe("ClientTaskManager", () => { logger.info(() => ({ msg: "Test: storeCommitmentsExternal completed" })); // Verification steps with logging - expect(providerDB.getUnstoredDappUserCommitments).toHaveBeenCalled(); + expect(providerDB.getUnstoredImageCaptchas).toHaveBeenCalled(); expect(providerDB.getUnstoredDappUserPoWCommitments).toHaveBeenCalled(); logger.info(() => ({ msg: "Test: Verified DB queries were made" })); @@ -373,7 +373,7 @@ describe("ClientTaskManager", () => { ); logger.info(() => ({ msg: "Test: Verified task status creation" })); - expect(providerDB.markDappUserCommitmentsStored).not.toHaveBeenCalled(); + expect(providerDB.markImageCaptchasStored).not.toHaveBeenCalled(); logger.info(() => ({ msg: "Test: Verified no image commitments were marked as stored (expected as they're old)", })); @@ -423,7 +423,7 @@ describe("ClientTaskManager", () => { collections.schedulers.time = 2; // biome-ignore lint/suspicious/noExplicitAny: TODO fix - (providerDB.getUnstoredDappUserCommitments as any).mockResolvedValueOnce( + (providerDB.getUnstoredImageCaptchas as any).mockResolvedValueOnce( [], ); @@ -434,7 +434,7 @@ describe("ClientTaskManager", () => { await clientTaskManager.storeCommitmentsExternal(); - expect(providerDB.markDappUserCommitmentsStored).not.toHaveBeenCalled(); + expect(providerDB.markImageCaptchasStored).not.toHaveBeenCalled(); expect(providerDB.markDappUserPoWCommitmentsStored).not.toHaveBeenCalled(); expect(providerDB.updateScheduledTaskStatus).toHaveBeenCalledWith( diff --git a/packages/provider/src/tests/unit/tasks/dataset/datasetTasks.unit.test.ts b/packages/provider/src/tests/unit/tasks/dataset/datasetTasks.unit.test.ts index 73826fc4d1..b2a7471ae1 100644 --- a/packages/provider/src/tests/unit/tasks/dataset/datasetTasks.unit.test.ts +++ b/packages/provider/src/tests/unit/tasks/dataset/datasetTasks.unit.test.ts @@ -95,8 +95,8 @@ describe("DatasetManager", () => { providerDB = { storeDataset: vi.fn(), - getUnstoredDappUserCommitments: vi.fn().mockResolvedValue([]), - markDappUserCommitmentsStored: vi.fn(), + getUnstoredImageCaptchas: vi.fn().mockResolvedValue([]), + markImageCaptchasStored: vi.fn(), markDappUserPoWCommitmentsStored: vi.fn(), getUnstoredDappUserPoWCommitments: vi.fn().mockResolvedValue([]), createScheduledTaskStatus: vi.fn( diff --git a/packages/provider/src/tests/unit/tasks/imgCaptcha/imgCaptchaTasks.unit.test.ts b/packages/provider/src/tests/unit/tasks/imgCaptcha/imgCaptchaTasks.unit.test.ts index 7fe223c556..6d9125236d 100644 --- a/packages/provider/src/tests/unit/tasks/imgCaptcha/imgCaptchaTasks.unit.test.ts +++ b/packages/provider/src/tests/unit/tasks/imgCaptcha/imgCaptchaTasks.unit.test.ts @@ -166,19 +166,19 @@ describe("ImgCaptchaManager", () => { getPendingImageCommitment: vi.fn(), updatePendingImageCommitmentStatus: vi.fn(), storeDappUserSolution: vi.fn(), - approveDappUserCommitment: vi.fn(), - disapproveDappUserCommitment: vi.fn(), - updateDappUserCommitment: vi.fn(), + approveImageCaptcha: vi.fn(), + disapproveImageCaptcha: vi.fn(), + updateImageCaptcha: vi.fn(), getCaptchaById: vi.fn(), - getDappUserCommitmentById: vi.fn(), - getDappUserCommitmentByAccount: vi.fn(), - markDappUserCommitmentsChecked: vi.fn(), + getImageCaptchaById: vi.fn(), + getImageCaptchaByAccount: vi.fn(), + markImageCaptchasChecked: vi.fn(), getSessionRecordBySessionId: vi.fn(), updateSessionRecord: vi.fn(), getSpamEmailDomain: vi.fn(), getClientRecord: vi.fn(), getSolutionByCaptchaId: vi.fn(), - storeUserImageCaptchaSolution: vi.fn(), + storeImageCaptchaSolution: vi.fn(), } as unknown as IProviderDatabase; pair = { @@ -550,12 +550,12 @@ describe("ImgCaptchaManager", () => { expect(result.verified).toBe(false); expect(compareCaptchaSolutions).toHaveBeenCalled(); - expect(db.disapproveDappUserCommitment).toHaveBeenCalledWith( + expect(db.disapproveImageCaptcha).toHaveBeenCalledWith( "commitmentId", "CAPTCHA.INVALID_SOLUTION", expect.anything(), ); - expect(db.approveDappUserCommitment).not.toHaveBeenCalled(); + expect(db.approveImageCaptcha).not.toHaveBeenCalled(); }); }); @@ -582,10 +582,10 @@ describe("ImgCaptchaManager", () => { lastUpdatedTimestamp: new Date(), }; // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue(dappUserCommitment); + (db.getImageCaptchaById as any).mockResolvedValue(dappUserCommitment); const result = - await imgCaptchaManager.getDappUserCommitmentById(commitmentId); + await imgCaptchaManager.getImageCaptchaById(commitmentId); expect(result).toEqual(dappUserCommitment); }); @@ -593,14 +593,14 @@ describe("ImgCaptchaManager", () => { it("should throw an error if dapp user commitment is not found by ID", async () => { const commitmentId = "commitmentId"; // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue(null); + (db.getImageCaptchaById as any).mockResolvedValue(null); await expect( - imgCaptchaManager.getDappUserCommitmentById(commitmentId), + imgCaptchaManager.getImageCaptchaById(commitmentId), ).rejects.toThrow( new ProsopoEnvError("CAPTCHA.DAPP_USER_SOLUTION_NOT_FOUND", { context: { - failedFuncName: "getDappUserCommitmentById", + failedFuncName: "getImageCaptchaById", commitmentId: commitmentId, }, }), @@ -633,11 +633,11 @@ describe("ImgCaptchaManager", () => { } as Partial, ]; // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentByAccount as any).mockResolvedValue( + (db.getImageCaptchaByAccount as any).mockResolvedValue( dappUserCommitments, ); - const result = await imgCaptchaManager.getDappUserCommitmentByAccount( + const result = await imgCaptchaManager.getImageCaptchaByAccount( userAccount, dappAccount, ); @@ -650,11 +650,11 @@ describe("ImgCaptchaManager", () => { const dappAccount = "dappAccount"; const dappUserCommitments: UserCommitment[] = []; // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentByAccount as any).mockResolvedValue( + (db.getImageCaptchaByAccount as any).mockResolvedValue( dappUserCommitments, ); - const result = await imgCaptchaManager.getDappUserCommitmentByAccount( + const result = await imgCaptchaManager.getImageCaptchaByAccount( userAccount, dappAccount, ); @@ -693,9 +693,9 @@ describe("ImgCaptchaManager", () => { // Mock database calls // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue(commitment); + (db.getImageCaptchaById as any).mockResolvedValue(commitment); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.disapproveDappUserCommitment as any).mockResolvedValue(undefined); + (db.disapproveImageCaptcha as any).mockResolvedValue(undefined); // Mock decision machine to return Deny const originalDecide = @@ -723,7 +723,7 @@ describe("ImgCaptchaManager", () => { expect(result.status).toBe("Suspicious behavior detected"); // Verify commitment was disapproved via streaming-aware method - expect(db.disapproveDappUserCommitment).toHaveBeenCalledWith( + expect(db.disapproveImageCaptcha).toHaveBeenCalledWith( commitmentId, expect.stringContaining("Suspicious behavior"), ); @@ -763,7 +763,7 @@ describe("ImgCaptchaManager", () => { // Mock database calls // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue(commitment); + (db.getImageCaptchaById as any).mockResolvedValue(commitment); // Mock decision machine to throw an error const originalDecide = @@ -820,7 +820,7 @@ describe("ImgCaptchaManager", () => { // Mock database calls // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue(commitment); + (db.getImageCaptchaById as any).mockResolvedValue(commitment); // Mock decision machine to return Allow const originalDecide = @@ -897,7 +897,7 @@ describe("ImgCaptchaManager", () => { }; // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue(commitment); + (db.getImageCaptchaById as any).mockResolvedValue(commitment); // biome-ignore lint/suspicious/noExplicitAny: tests (db.getSessionRecordBySessionId as any).mockResolvedValue(undefined); @@ -956,11 +956,11 @@ describe("ImgCaptchaManager", () => { }; // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue(commitment); + (db.getImageCaptchaById as any).mockResolvedValue(commitment); // biome-ignore lint/suspicious/noExplicitAny: tests (db.getSessionRecordBySessionId as any).mockResolvedValue(undefined); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.disapproveDappUserCommitment as any).mockResolvedValue(undefined); + (db.disapproveImageCaptcha as any).mockResolvedValue(undefined); // Mock decision machine to deny const originalDecide = @@ -1023,7 +1023,7 @@ describe("ImgCaptchaManager", () => { }; // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue(commitment); + (db.getImageCaptchaById as any).mockResolvedValue(commitment); // Mock decision machine to allow const originalDecide = @@ -1081,15 +1081,15 @@ describe("ImgCaptchaManager", () => { // Mock database calls // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue(commitment); + (db.getImageCaptchaById as any).mockResolvedValue(commitment); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.markDappUserCommitmentsChecked as any).mockResolvedValue(undefined); + (db.markImageCaptchasChecked as any).mockResolvedValue(undefined); // biome-ignore lint/suspicious/noExplicitAny: tests (db.getSpamEmailDomain as any).mockResolvedValue({ domain: "spammydomain.com", }); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.disapproveDappUserCommitment as any).mockResolvedValue(undefined); + (db.disapproveImageCaptcha as any).mockResolvedValue(undefined); const result = await imgCaptchaManager.verifyImageCaptchaSolution( userAccount, @@ -1108,7 +1108,7 @@ describe("ImgCaptchaManager", () => { expect(result.verified).toBe(false); expect(result.status).toBe("API.SPAM_EMAIL_DOMAIN"); expect(db.getSpamEmailDomain).toHaveBeenCalledWith("spammydomain.com"); - expect(db.disapproveDappUserCommitment).toHaveBeenCalledWith( + expect(db.disapproveImageCaptcha).toHaveBeenCalledWith( commitmentId, "API.SPAM_EMAIL_DOMAIN", ); @@ -1145,13 +1145,13 @@ describe("ImgCaptchaManager", () => { // Mock database calls // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue(commitment); + (db.getImageCaptchaById as any).mockResolvedValue(commitment); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.markDappUserCommitmentsChecked as any).mockResolvedValue(undefined); + (db.markImageCaptchasChecked as any).mockResolvedValue(undefined); // biome-ignore lint/suspicious/noExplicitAny: tests (db.getSpamEmailDomain as any).mockResolvedValue(null); - // Reset disapproveDappUserCommitment mock - db.disapproveDappUserCommitment = vi + // Reset disapproveImageCaptcha mock + db.disapproveImageCaptcha = vi .fn() // biome-ignore lint/suspicious/noExplicitAny: tests .mockResolvedValue(undefined) as any; @@ -1187,7 +1187,7 @@ describe("ImgCaptchaManager", () => { expect(result.verified).toBe(true); expect(result.status).toBe("API.USER_VERIFIED"); expect(db.getSpamEmailDomain).toHaveBeenCalledWith("legitimate.com"); - expect(db.disapproveDappUserCommitment).not.toHaveBeenCalledWith( + expect(db.disapproveImageCaptcha).not.toHaveBeenCalledWith( commitmentId, "API.SPAM_EMAIL_DOMAIN", ); @@ -1229,9 +1229,9 @@ describe("ImgCaptchaManager", () => { // Mock database calls // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue(commitment); + (db.getImageCaptchaById as any).mockResolvedValue(commitment); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.markDappUserCommitmentsChecked as any).mockResolvedValue(undefined); + (db.markImageCaptchasChecked as any).mockResolvedValue(undefined); // Mock decision machine to allow const originalDecide = @@ -1303,15 +1303,15 @@ describe("ImgCaptchaManager", () => { // Mock database calls // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue(commitment); + (db.getImageCaptchaById as any).mockResolvedValue(commitment); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.markDappUserCommitmentsChecked as any).mockResolvedValue(undefined); + (db.markImageCaptchasChecked as any).mockResolvedValue(undefined); // biome-ignore lint/suspicious/noExplicitAny: tests (db.getSpamEmailDomain as any).mockResolvedValue({ domain: "spammydomain.com", }); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.disapproveDappUserCommitment as any).mockResolvedValue(undefined); + (db.disapproveImageCaptcha as any).mockResolvedValue(undefined); const result = await imgCaptchaManager.verifyImageCaptchaSolution( userAccount, @@ -1330,7 +1330,7 @@ describe("ImgCaptchaManager", () => { expect(result.verified).toBe(false); expect(result.status).toBe("API.SPAM_EMAIL_DOMAIN"); expect(db.getSpamEmailDomain).toHaveBeenCalledWith("spammydomain.com"); - expect(db.disapproveDappUserCommitment).toHaveBeenCalledWith( + expect(db.disapproveImageCaptcha).toHaveBeenCalledWith( commitmentId, "API.SPAM_EMAIL_DOMAIN", ); @@ -1367,15 +1367,15 @@ describe("ImgCaptchaManager", () => { // Mock database calls // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue(commitment); + (db.getImageCaptchaById as any).mockResolvedValue(commitment); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.markDappUserCommitmentsChecked as any).mockResolvedValue(undefined); + (db.markImageCaptchasChecked as any).mockResolvedValue(undefined); // biome-ignore lint/suspicious/noExplicitAny: tests (db.getSpamEmailDomain as any).mockResolvedValue({ domain: "spammydomain.com", }); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.disapproveDappUserCommitment as any).mockResolvedValue(undefined); + (db.disapproveImageCaptcha as any).mockResolvedValue(undefined); const result = await imgCaptchaManager.verifyImageCaptchaSolution( userAccount, @@ -1427,9 +1427,9 @@ describe("ImgCaptchaManager", () => { // Mock database calls // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue(commitment); + (db.getImageCaptchaById as any).mockResolvedValue(commitment); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.markDappUserCommitmentsChecked as any).mockResolvedValue(undefined); + (db.markImageCaptchasChecked as any).mockResolvedValue(undefined); // Mock database error when checking spam // biome-ignore lint/suspicious/noExplicitAny: tests (db.getSpamEmailDomain as any).mockRejectedValue( @@ -1476,7 +1476,7 @@ describe("ImgCaptchaManager", () => { } }); - it("should not call disapproveDappUserCommitment when commitmentId is undefined", async () => { + it("should not call disapproveImageCaptcha when commitmentId is undefined", async () => { const userAccount = "userAccount"; const dappAccount = "dappAccount"; const spamEmail = "user@spammydomain.com"; @@ -1506,16 +1506,16 @@ describe("ImgCaptchaManager", () => { // Mock database calls to find commitment by account // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentByAccount as any).mockResolvedValue([ + (db.getImageCaptchaByAccount as any).mockResolvedValue([ commitment, ]); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.markDappUserCommitmentsChecked as any).mockResolvedValue(undefined); + (db.markImageCaptchasChecked as any).mockResolvedValue(undefined); // biome-ignore lint/suspicious/noExplicitAny: tests (db.getSpamEmailDomain as any).mockResolvedValue({ domain: "spammydomain.com", }); - db.disapproveDappUserCommitment = vi + db.disapproveImageCaptcha = vi .fn() // biome-ignore lint/suspicious/noExplicitAny: tests .mockResolvedValue(undefined) as any; @@ -1538,7 +1538,7 @@ describe("ImgCaptchaManager", () => { expect(result.status).toBe("API.SPAM_EMAIL_DOMAIN"); expect(db.getSpamEmailDomain).toHaveBeenCalledWith("spammydomain.com"); // Should not call disapprove when commitmentId is undefined - expect(db.disapproveDappUserCommitment).not.toHaveBeenCalled(); + expect(db.disapproveImageCaptcha).not.toHaveBeenCalled(); }); }); @@ -1573,11 +1573,11 @@ describe("ImgCaptchaManager", () => { const commitmentId = "webviewScoreCommitmentId"; // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue( + (db.getImageCaptchaById as any).mockResolvedValue( baseCommitment(commitmentId), ); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.markDappUserCommitmentsChecked as any).mockResolvedValue(undefined); + (db.markImageCaptchasChecked as any).mockResolvedValue(undefined); // biome-ignore lint/suspicious/noExplicitAny: tests (db.getSessionRecordBySessionId as any).mockResolvedValue({ sessionId, @@ -1586,7 +1586,7 @@ describe("ImgCaptchaManager", () => { reason: "WEBVIEW_DETECTED", }); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.disapproveDappUserCommitment as any).mockResolvedValue(undefined); + (db.disapproveImageCaptcha as any).mockResolvedValue(undefined); const result = await imgCaptchaManager.verifyImageCaptchaSolution( "userAccount", @@ -1600,7 +1600,7 @@ describe("ImgCaptchaManager", () => { expect(result.verified).toBe(false); expect(result.status).toBe("API.DISALLOWED_WEBVIEW"); - expect(db.disapproveDappUserCommitment).toHaveBeenCalledWith( + expect(db.disapproveImageCaptcha).toHaveBeenCalledWith( commitmentId, "API.DISALLOWED_WEBVIEW", ); @@ -1616,18 +1616,18 @@ describe("ImgCaptchaManager", () => { true, ); // Decision machine must not be consulted once the request is blocked. - expect(db.approveDappUserCommitment).not.toHaveBeenCalled(); + expect(db.approveImageCaptcha).not.toHaveBeenCalled(); }); it("should disapprove when session.webView is true even if scoreComponents.webView is unset", async () => { const commitmentId = "webviewBoolCommitmentId"; // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue( + (db.getImageCaptchaById as any).mockResolvedValue( baseCommitment(commitmentId), ); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.markDappUserCommitmentsChecked as any).mockResolvedValue(undefined); + (db.markImageCaptchasChecked as any).mockResolvedValue(undefined); // Webview detected but reached image captcha via a non-webview // frictionless branch (UA mismatch, context-aware, etc.), so // `scoreComponents.webView` is absent. @@ -1639,7 +1639,7 @@ describe("ImgCaptchaManager", () => { reason: "USER_AGENT_MISMATCH", }); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.disapproveDappUserCommitment as any).mockResolvedValue(undefined); + (db.disapproveImageCaptcha as any).mockResolvedValue(undefined); const result = await imgCaptchaManager.verifyImageCaptchaSolution( "userAccount", @@ -1653,7 +1653,7 @@ describe("ImgCaptchaManager", () => { expect(result.verified).toBe(false); expect(result.status).toBe("API.DISALLOWED_WEBVIEW"); - expect(db.disapproveDappUserCommitment).toHaveBeenCalledWith( + expect(db.disapproveImageCaptcha).toHaveBeenCalledWith( commitmentId, "API.DISALLOWED_WEBVIEW", ); @@ -1663,11 +1663,11 @@ describe("ImgCaptchaManager", () => { const commitmentId = "webviewAllowedCommitmentId"; // biome-ignore lint/suspicious/noExplicitAny: tests - (db.getDappUserCommitmentById as any).mockResolvedValue( + (db.getImageCaptchaById as any).mockResolvedValue( baseCommitment(commitmentId), ); // biome-ignore lint/suspicious/noExplicitAny: tests - (db.markDappUserCommitmentsChecked as any).mockResolvedValue(undefined); + (db.markImageCaptchasChecked as any).mockResolvedValue(undefined); // biome-ignore lint/suspicious/noExplicitAny: tests (db.getSessionRecordBySessionId as any).mockResolvedValue({ sessionId, @@ -1696,7 +1696,7 @@ describe("ImgCaptchaManager", () => { expect(result.verified).toBe(true); expect(result.status).toBe("API.USER_VERIFIED"); - expect(db.approveDappUserCommitment).toHaveBeenCalledWith(commitmentId); + expect(db.approveImageCaptcha).toHaveBeenCalledWith(commitmentId); } finally { // biome-ignore lint/suspicious/noExplicitAny: tests (imgCaptchaManager as any).decisionMachineRunner.decide = diff --git a/packages/types-database/src/types/captcha.ts b/packages/types-database/src/types/captcha.ts index 9adc69adb0..a8a4fd0c82 100644 --- a/packages/types-database/src/types/captcha.ts +++ b/packages/types-database/src/types/captcha.ts @@ -12,16 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { PoWCaptcha, UserCommitment } from "@prosopo/types"; +import type { ImageCaptcha, PoWCaptcha } from "@prosopo/types"; import { type RootFilterQuery, Schema } from "mongoose"; import type { IDatabase } from "./mongo.js"; import { + type ImageCaptchaRecord, + ImageCaptchaRecordSchema, type PoWCaptchaRecord, PoWCaptchaRecordSchema, type SessionRecord, SessionRecordSchema, - type UserCommitmentRecord, - UserCommitmentRecordSchema, } from "./provider.js"; // StoredSession is now the same as SessionRecord since we merged the schemas @@ -29,10 +29,15 @@ export type StoredSession = SessionRecord; export const StoredSessionRecordSchema: Schema = SessionRecordSchema; -export const StoredUserCommitmentRecordSchema: Schema = new Schema({ - ...UserCommitmentRecordSchema.obj, +export const StoredImageCaptchaRecordSchema: Schema = new Schema({ + ...ImageCaptchaRecordSchema.obj, }); -StoredUserCommitmentRecordSchema.index({ sessionId: 1 }); +StoredImageCaptchaRecordSchema.index({ sessionId: 1 }); + +/** + * @deprecated Use {@link StoredImageCaptchaRecordSchema}. Back-compat alias. + */ +export const StoredUserCommitmentRecordSchema = StoredImageCaptchaRecordSchema; export const StoredPoWCaptchaRecordSchema: Schema = new Schema({ ...PoWCaptchaRecordSchema.obj, @@ -42,17 +47,17 @@ StoredPoWCaptchaRecordSchema.index({ sessionId: 1 }); export interface ICaptchaDatabase extends IDatabase { saveCaptchas( sessionEvents: StoredSession[], - imageCaptchaEvents: UserCommitmentRecord[], + imageCaptchaEvents: ImageCaptchaRecord[], powCaptchaEvents: PoWCaptchaRecord[], ): Promise; getCaptchas( filter: RootFilterQuery, limit: number, ): Promise<{ - userCommitmentRecords: UserCommitmentRecord[]; + imageCaptchaRecords: ImageCaptchaRecord[]; powCaptchaRecords: PoWCaptchaRecord[]; }>; } export interface CaptchaProperties - extends Partial {} + extends Partial {} diff --git a/packages/types-database/src/types/provider.ts b/packages/types-database/src/types/provider.ts index 8920105966..c0f4d5de3b 100644 --- a/packages/types-database/src/types/provider.ts +++ b/packages/types-database/src/types/provider.ts @@ -25,16 +25,17 @@ import { DecisionMachineRuntime, DecisionMachineScope, type DetectorKey, + type ImageCaptcha, IpAddressType, ModeEnum, type PendingImageCaptchaRequest, type PoWCaptchaStored, type PuzzleCaptchaStored, + RequestType, type Session, type SimdReadingsStage, type SolutionRecord, Tier, - type UserCommitment, type UserSolutionSchema, } from "@prosopo/types"; import { @@ -114,7 +115,13 @@ export type PoWCaptchaRecord = mongoose.Document & PoWCaptchaStored; export type PuzzleCaptchaRecord = mongoose.Document & PuzzleCaptchaStored; -export type UserCommitmentRecord = mongoose.Document & UserCommitment; +export type ImageCaptchaRecord = mongoose.Document & ImageCaptcha; + +/** + * @deprecated Use {@link ImageCaptchaRecord}. Back-compat alias; remove in a + * later cleanup once consumers migrate off the old name. + */ +export type UserCommitmentRecord = ImageCaptchaRecord; export type Tables = { // biome-ignore lint/suspicious/noExplicitAny: @@ -152,6 +159,12 @@ CaptchaRecordSchema.index({ datasetId: 1 }); CaptchaRecordSchema.index({ datasetId: 1, solved: 1 }); export const PoWCaptchaRecordSchema = new Schema({ + requestType: { + type: String, + enum: RequestType, + required: false, + default: RequestType.powcaptcha, + }, challenge: { type: String, required: true }, dappAccount: { type: String, required: true }, userAccount: { type: String, required: true }, @@ -255,6 +268,12 @@ PoWCaptchaRecordSchema.index( ); export const PuzzleCaptchaRecordSchema = new Schema({ + requestType: { + type: String, + enum: RequestType, + required: false, + default: RequestType.puzzlecaptcha, + }, challenge: { type: String, required: true }, dappAccount: { type: String, required: true }, userAccount: { type: String, required: true }, @@ -354,7 +373,13 @@ PuzzleCaptchaRecordSchema.index( }, ); -export const UserCommitmentRecordSchema = new Schema({ +export const ImageCaptchaRecordSchema = new Schema({ + requestType: { + type: String, + enum: RequestType, + required: false, + default: RequestType.imagecaptcha, + }, userAccount: { type: String, required: true }, dappAccount: { type: String, required: true }, providerAccount: { type: String, required: true }, @@ -429,23 +454,23 @@ export const UserCommitmentRecordSchema = new Schema({ labelledAt: { type: Date, required: false }, }); // Set an index on the commitment id field, descending -UserCommitmentRecordSchema.index({ id: -1 }); +ImageCaptchaRecordSchema.index({ id: -1 }); // Supports the labelled-dataset export query (`{ label: { $exists: true } }`). -UserCommitmentRecordSchema.index({ label: 1, dappAccount: 1 }); -UserCommitmentRecordSchema.index({ +ImageCaptchaRecordSchema.index({ label: 1, dappAccount: 1 }); +ImageCaptchaRecordSchema.index({ lastUpdatedTimestamp: 1, }); -UserCommitmentRecordSchema.index({ userAccount: 1, dappAccount: 1 }); -UserCommitmentRecordSchema.index({ "ipAddress.lower": 1 }); -UserCommitmentRecordSchema.index({ "ipAddress.upper": 1 }); -UserCommitmentRecordSchema.index({ "result.reason": 1 }); -UserCommitmentRecordSchema.index({ "ipInfo.countryCode": 1 }); -UserCommitmentRecordSchema.index({ "ipInfo.isVPN": 1 }); -UserCommitmentRecordSchema.index({ requestHash: -1 }); -UserCommitmentRecordSchema.index({ pending: 1 }); -UserCommitmentRecordSchema.index({ ipInfo: 1 }); -UserCommitmentRecordSchema.index({ parsedUserAgentInfo: 1 }); -UserCommitmentRecordSchema.index( +ImageCaptchaRecordSchema.index({ userAccount: 1, dappAccount: 1 }); +ImageCaptchaRecordSchema.index({ "ipAddress.lower": 1 }); +ImageCaptchaRecordSchema.index({ "ipAddress.upper": 1 }); +ImageCaptchaRecordSchema.index({ "result.reason": 1 }); +ImageCaptchaRecordSchema.index({ "ipInfo.countryCode": 1 }); +ImageCaptchaRecordSchema.index({ "ipInfo.isVPN": 1 }); +ImageCaptchaRecordSchema.index({ requestHash: -1 }); +ImageCaptchaRecordSchema.index({ pending: 1 }); +ImageCaptchaRecordSchema.index({ ipInfo: 1 }); +ImageCaptchaRecordSchema.index({ parsedUserAgentInfo: 1 }); +ImageCaptchaRecordSchema.index( { pendingStage: 1 }, { name: "pendingStage_partial", @@ -453,6 +478,12 @@ UserCommitmentRecordSchema.index( }, ); +/** + * @deprecated Use {@link ImageCaptchaRecordSchema}. Back-compat alias; remove in + * a later cleanup. + */ +export const UserCommitmentRecordSchema = ImageCaptchaRecordSchema; + export const DatasetRecordSchema = new Schema({ contentTree: { type: [[String]], required: true }, datasetContentId: { type: String, required: true }, @@ -534,6 +565,12 @@ ScheduledTaskRecordSchema.index({ _id: 1, status: 1 }); export type SessionRecord = mongoose.Document & Session; export const SessionRecordSchema = new Schema({ + requestType: { + type: String, + enum: RequestType, + required: false, + default: RequestType.session, + }, sessionId: { type: String, required: true }, createdAt: { type: Date, required: true }, token: { type: String, required: true }, @@ -568,7 +605,7 @@ export const SessionRecordSchema = new Schema({ blocked: { type: Boolean, required: false }, // Full ipinfo payload — replaces flat `countryCode` / `geolocation` // fields. Mirrors the captcha record schemas (PoW / Puzzle / - // UserCommitment). + // ImageCaptcha). ipInfo: { type: Object, required: false }, headers: { type: Object, required: false }, result: { @@ -726,9 +763,9 @@ export interface IProviderDatabase extends IDatabase { datasetId: Hash | string | Uint8Array, ): Promise; - storeUserImageCaptchaSolution( + storeImageCaptchaSolution( captchas: CaptchaSolution[], - commit: UserCommitment, + commit: ImageCaptcha, ): Promise; storePendingImageCommitment( @@ -771,43 +808,43 @@ export interface IProviderDatabase extends IDatabase { commitmentId: string, ): Promise; - getDappUserCommitmentById( + getImageCaptchaById( commitmentId: string, - ): Promise; + ): Promise; - getDappUserCommitmentByAccount( + getImageCaptchaByAccount( userAccount: string, dappAccount: string, - ): Promise; + ): Promise; - approveDappUserCommitment( + approveImageCaptcha( commitmentId: string, coords?: [number, number][][], ): Promise; - disapproveDappUserCommitment( + disapproveImageCaptcha( commitmentId: string, reason?: TranslationKey, coords?: [number, number][][], ): Promise; - getCheckedDappUserCommitments(): Promise; + getCheckedImageCaptchas(): Promise; - getUnstoredDappUserCommitments( + getUnstoredImageCaptchas( limit?: number, skip?: number, - ): Promise; + ): Promise; - markDappUserCommitmentsStored( + markImageCaptchasStored( commitmentIds: Hash[], asOfTimestamp?: Date, ): Promise; - markDappUserCommitmentsChecked(commitmentIds: Hash[]): Promise; + markImageCaptchasChecked(commitmentIds: Hash[]): Promise; - updateDappUserCommitment( + updateImageCaptcha( commitmentId: Hash, - updates: Partial, + updates: Partial, ): Promise; getUnstoredDappUserPoWCommitments( @@ -824,7 +861,7 @@ export interface IProviderDatabase extends IDatabase { flagProcessedDappUserSolutions(captchaIds: Hash[]): Promise; - flagProcessedDappUserCommitments(commitmentIds: Hash[]): Promise; + flagProcessedImageCaptchas(commitmentIds: Hash[]): Promise; getLastScheduledTaskStatus( task: ScheduledTaskNames, diff --git a/packages/types/src/provider/database.ts b/packages/types/src/provider/database.ts index f439f66005..6f0190ac95 100644 --- a/packages/types/src/provider/database.ts +++ b/packages/types/src/provider/database.ts @@ -50,6 +50,7 @@ import type { import type { PuzzleEvent, RequestHeaders } from "./api.js"; import type { SimdReadings } from "./detection.js"; import type { FrictionlessReason, ResultReason } from "./reasons.js"; +import { RequestType, RequestTypeSchema } from "./requestType.js"; export interface BrowserInfo { name: string; @@ -165,7 +166,30 @@ export enum CaptchaLabel { export const CaptchaLabelSchema = nativeEnum(CaptchaLabel); +/** + * Fields common to every persisted captcha request, regardless of kind + * (session / powcaptcha / imagecaptcha / puzzle). This is the shared shape the + * consolidation builds on: the `requestType` discriminator plus the + * cross-cutting correlation / staging fields that already exist on every + * record. Kept additive for now — `StoredCaptcha` and `Session` carry these + * fields directly rather than `extends`ing this, so no existing record shape is + * tightened. Once the records live in one `requests` collection this becomes + * the required base for the discriminated union. + */ +export interface RequestRecord { + requestType?: RequestType; + sessionId?: string; + requestedAtTimestamp?: Date; + lastUpdatedTimestamp?: Date; + storedAtTimestamp?: Date; + pendingStage?: boolean; +} + export interface StoredCaptcha { + // Discriminator for the unified request model. Optional for back-compat + // with records written before the field existed; writers stamp it via the + // mongoose schema default. See {@link RequestRecord}. + requestType?: RequestType; result: { status: CaptchaStatus; reason?: ResultReason; @@ -216,7 +240,7 @@ export interface StoredCaptcha { labelledAt?: Date; } -export interface UserCommitment extends StoredCaptcha { +export interface ImageCaptcha extends StoredCaptcha { userAccount: string; dappAccount: string; datasetId: string; @@ -230,6 +254,12 @@ export interface UserCommitment extends StoredCaptcha { deadlineTimestamp: Date; } +/** + * @deprecated Use {@link ImageCaptcha}. Retained as a back-compat alias while + * consumers migrate off the old name; remove in a later cleanup. + */ +export type UserCommitment = ImageCaptcha; + // Runtime parsing stays permissive (`string().optional()`) because decision // machines are operator-authored JS — their `reason` is whatever string the // machine returns, including values that won't be in `ResultReason`. The @@ -260,7 +290,7 @@ export const ClientMetaDataDbSchema = object({ hp: string().optional(), }) satisfies ZodType; -export const UserCommitmentSchema = object({ +export const ImageCaptchaSchema = object({ userAccount: string(), dappAccount: string(), datasetId: string(), @@ -280,6 +310,7 @@ export const UserCommitmentSchema = object({ requestedAtTimestamp: date(), lastUpdatedTimestamp: date().optional(), pendingStage: boolean().optional(), + requestType: RequestTypeSchema.optional(), sessionId: string().optional(), coords: array(array(tuple([number(), number()]))).optional(), // Pending request fields for image captcha workflow @@ -296,7 +327,13 @@ export const UserCommitmentSchema = object({ labelReason: string().optional(), labelledBy: string().optional(), labelledAt: date().optional(), -}) satisfies ZodType; +}) satisfies ZodType; + +/** + * @deprecated Use {@link ImageCaptchaSchema}. Back-compat alias; remove in a + * later cleanup. + */ +export const UserCommitmentSchema = ImageCaptchaSchema; // Zod schema for ScoreComponents export const ScoreComponentsSchema = object({ @@ -379,6 +416,7 @@ export const SessionSchema = object({ storedAtTimestamp: date().optional(), lastUpdatedTimestamp: date().optional(), pendingStage: boolean().optional(), + requestType: RequestTypeSchema.optional(), deleted: boolean().optional(), userSitekeyIpHash: string().optional(), webView: boolean(), @@ -438,6 +476,10 @@ export type Session = { lastUpdatedTimestamp?: Date; // See StoredCaptcha.pendingStage — same semantics on Session records. pendingStage?: boolean; + // Discriminator for the unified request model. Always `RequestType.session` + // for session records; stamped via the mongoose schema default. Optional for + // back-compat with records written before the field existed. + requestType?: RequestType; deleted?: boolean; userSitekeyIpHash?: string; webView: boolean; @@ -494,6 +536,7 @@ export const PoWCaptchaStoredSchema = object({ parsedUserAgentInfo: any().optional(), storedAtTimestamp: date().optional(), lastUpdatedTimestamp: date().optional(), + requestType: RequestTypeSchema.optional(), sessionId: string().optional(), coords: array(array(tuple([number(), number()]))).optional(), mouseEvents: array(object({}).catchall(any())).optional(), @@ -552,14 +595,24 @@ export const UserSolutionSchema = CaptchaSolutionSchema.extend({ export type UserSolution = zInfer; -export const UserCommitmentWithSolutionsSchema = UserCommitmentSchema.extend({ +export const ImageCaptchaWithSolutionsSchema = ImageCaptchaSchema.extend({ captchas: array(UserSolutionSchema), }); -export type UserCommitmentWithSolutions = zInfer< - typeof UserCommitmentWithSolutionsSchema +export type ImageCaptchaWithSolutions = zInfer< + typeof ImageCaptchaWithSolutionsSchema >; +/** + * @deprecated Use {@link ImageCaptchaWithSolutionsSchema}. Back-compat alias. + */ +export const UserCommitmentWithSolutionsSchema = ImageCaptchaWithSolutionsSchema; + +/** + * @deprecated Use {@link ImageCaptchaWithSolutions}. Back-compat alias. + */ +export type UserCommitmentWithSolutions = ImageCaptchaWithSolutions; + export type DetectorKey = { detectorKey: string; createdAt: Date; diff --git a/packages/types/src/provider/index.ts b/packages/types/src/provider/index.ts index 7f0c37d56a..15159f443d 100644 --- a/packages/types/src/provider/index.ts +++ b/packages/types/src/provider/index.ts @@ -15,5 +15,6 @@ export * from "./accounts.js"; export * from "./api.js"; export * from "./scheduler.js"; export * from "./detection.js"; +export * from "./requestType.js"; export * from "./database.js"; export * from "./reasons.js"; diff --git a/packages/types/src/provider/requestType.ts b/packages/types/src/provider/requestType.ts new file mode 100644 index 0000000000..74a3423710 --- /dev/null +++ b/packages/types/src/provider/requestType.ts @@ -0,0 +1,40 @@ +// Copyright 2021-2026 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { nativeEnum } from "zod"; + +/** + * Discriminator for the kind of captcha request a stored record represents. + * + * This is an orthogonal axis to {@link CaptchaType}: `CaptchaType` describes the + * challenge served to the user (image / pow / puzzle / frictionless), whereas + * `RequestType` describes which record collection / shape the row belongs to. + * A `session` record has no `CaptchaType` equivalent, which is why this is a + * separate enum rather than an extension of `CaptchaType`. + * + * Records are currently stored in separate physical collections; `requestType` + * makes each record self-describing ahead of consolidating them into a single + * `requests` collection. The discriminator is stamped on every record kind via + * the mongoose schema default; the physical collections are unchanged until the + * later consolidation phase. + */ +export enum RequestType { + session = "session", + powcaptcha = "powcaptcha", + imagecaptcha = "imagecaptcha", + // Puzzle records keep their existing shape and collection; the discriminator + // is stamped so they can join the unified collection without a backfill. + puzzlecaptcha = "puzzlecaptcha", +} + +export const RequestTypeSchema = nativeEnum(RequestType);