Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/puzzle-maintenance-and-frictionless-refactor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@prosopo/provider": minor
"@prosopo/procaptcha-puzzle": minor
"@prosopo/env": minor
"@prosopo/database": minor
"@prosopo/api": patch
"@prosopo/types": patch
---

Puzzle captcha + maintenance mode hardening, plus a refactor of the
frictionless handler into focused modules.

- **Puzzle captcha now records checkbox-click coordinates like POW.** Adds an
optional `salt` field to `SubmitPuzzleCaptchaSolutionBody`; the puzzle
widget hashes the click coords into the salt and the server decodes them
into the puzzle record's `coords` field on submit. New `start(x, y)`
parameters on `procaptcha-puzzle` Manager + widget.
- **Fix puzzle "No session found" caused by stale Redis dedup.** The
`/frictionless` dedup path is now Mongo-authoritative — Redis is no
longer consulted as a session source. A concurrent `/captcha/{type}`
invalidation could previously race a fire-and-forget Redis repopulation
in the `/frictionless` dedup branch, leaving Redis pointing at a
Mongo-deleted session for the full 1-hour TTL. Stale pointers are now
evicted lazily.
- **Maintenance mode operates without MongoDB.** `/frictionless` and
`/captcha/{pow,puzzle}` short-circuit to dummy responses before any DB
call, and `Environment.isReady()` tolerates a Mongo connect failure when
`MAINTENANCE_MODE=true` so the provider can start with Mongo down.
- **Refactor `getFrictionlessCaptchaChallenge.ts` into focused modules** under
`getFrictionlessCaptchaChallenge/` (handler, sessionDedup, shortCircuit,
accessPolicy, decisionMachine, decryptSimdReadings, constants). Original
import path preserved via a re-export shim.
- **Move `RedisWriteQueue` from `@prosopo/provider` to `@prosopo/database`**
(where the Redis connection itself lives), and clear residual Redis
session keys at provider startup via `Environment.cleanup()` so a
previously-crashed run can't leak stale dedup pointers.
- Adds puzzle-type branch to access-policy handling in `/frictionless`.
2 changes: 2 additions & 0 deletions packages/api/src/api/ProviderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export default class ProviderApi
userTimestampSignature: string,
timeout?: number,
behavioralData?: string,
salt?: string,
simdReadings?: string,
): Promise<PuzzleCaptchaSolutionResponse> {
const body = SubmitPuzzleCaptchaSolutionBody.parse({
Expand All @@ -273,6 +274,7 @@ export default class ProviderApi
},
},
...(behavioralData && { [ApiParams.behavioralData]: behavioralData }),
...(salt && { [ApiParams.salt]: salt }),
...(simdReadings && { [ApiParams.simdReadings]: simdReadings }),
});
return this.post(ClientApiPaths.SubmitPuzzleCaptchaSolution, body, {
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,13 @@ export async function start(
// Get rid of any scheduled task records from previous runs
env.cleanup();

// Skip schedulers in maintenance mode — they all build a Tasks instance
// which requires a connected DB, and would just log noise on every tick.
const maintenanceMode =
process.env.MAINTENANCE_MODE?.toLowerCase() === "true";

// Start the scheduled jobs if they are defined
if (env.pair) {
if (env.pair && !maintenanceMode) {
const cronScheduleStorage =
env.config.scheduledTasks?.captchaScheduler?.schedule;
if (cronScheduleStorage) {
Expand Down
1 change: 1 addition & 0 deletions packages/database/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ import makeDir from "make-dir";
console.debug(makeDir);
export * from "./base/index.js";
export * from "./databases/index.js";
export * from "./redisCache.js";
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ const bigIntReviver = (_key: string, value: unknown): unknown =>
? BigInt(value.slice(BIGINT_TAG.length))
: value;

const SESSION_KEY_PATTERNS = [
"cache:session:*",
"writeq:session:*",
"writeq:session:pending",
] as const;

/**
* Redis-backed write queue and read cache for reducing MongoDB load.
*
Expand Down Expand Up @@ -428,6 +434,70 @@ export class RedisWriteQueue {
this.earlyFlushThreshold = undefined;
}

/**
* Drop every session-related key from Redis. Intended for provider
* startup so a fresh process can never inherit stale read-cache,
* dedup-by-hash, or pending-write-queue entries written by an
* earlier (possibly crashed) run.
*
* Uses SCAN — KEYS would block the Redis server on large keyspaces.
*
* Bypasses the `isReady()` fast-path that other methods use because
* this runs during startup, before the Redis client's async
* connect() handshake has had time to flip the flag. We await the
* connection promise directly with a bounded timeout so we don't
* hang the boot sequence when Redis is unreachable.
*/
async clearAllSessionRecords(timeoutMs = 5000): Promise<void> {
this.logger.info(() => ({
msg: "Clearing Redis session records at startup",
}));

let client: RedisClient;
try {
client = await Promise.race<RedisClient>([
this.connection.getClient(),
new Promise<RedisClient>((_, reject) =>
setTimeout(
() => reject(new Error("Redis connection timeout")),
timeoutMs,
),
),
]);
} catch (error) {
this.logger.warn(() => ({
msg: "Skipped Redis session cleanup — Redis not reachable",
err: error,
}));
return;
}

try {
let totalDeleted = 0;
for (const pattern of SESSION_KEY_PATTERNS) {
for await (const key of client.scanIterator({
MATCH: pattern,
COUNT: 500,
})) {
const keys = Array.isArray(key) ? key : [key];
if (keys.length > 0) {
await client.del(keys);
totalDeleted += keys.length;
}
}
}
this.logger.info(() => ({
msg: "Cleared Redis session records at startup",
data: { totalDeleted },
}));
} catch (error) {
this.logger.warn(() => ({
msg: "Failed to clear Redis session records at startup",
err: error,
}));
}
}

/**
* Check if the underlying Redis connection is ready.
*/
Expand Down
4,108 changes: 4,107 additions & 1 deletion packages/detector/src/index.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/env/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"build:cross-env": "vite build --config vite.esm.config.ts",
"build:tsc": "tsc --build --verbose",
"build:cjs": "NODE_ENV=${NODE_ENV:-development}; vite build --config vite.cjs.config.ts --mode $NODE_ENV",
"test": "NODE_ENV=${NODE_ENV:-test}; npx vitest run --config ./vite.test.config.ts --mode $NODE_ENV",
"typecheck": "tsc --project tsconfig.types.json"
},
"dependencies": {
Expand Down
20 changes: 17 additions & 3 deletions packages/env/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,19 @@ export class Environment implements ProsopoEnvironment {
this.pair.unlock(this.config.account.password);
}
await this.getSigner();
if (!this.db) {

const maintenanceMode = isMaintenanceMode();

// In maintenance mode we skip the DB connect entirely so a slow
// Mongo socket can't gate boot. Handlers short-circuit before
// touching the DB while the flag is on.
if (maintenanceMode) {
this.logger.warn(() => ({
msg: "MAINTENANCE_MODE=true — skipping DB import on startup",
}));
} else if (!this.db) {
await this.importDatabase();
}
if (this.db && !this.db.connected) {
} else if (this.db && !this.db.connected) {
this.logger.warn(() => ({
msg: `Database connection is not ready (state: ${this.db?.connection?.readyState}), reconnecting...`,
}));
Expand Down Expand Up @@ -191,3 +200,8 @@ export class Environment implements ProsopoEnvironment {
}
}
}

// Read directly from process.env to avoid a cyclic dep on the provider
// package (which owns the runtime toggle endpoint). Same env var name.
export const isMaintenanceMode = (): boolean =>
process.env.MAINTENANCE_MODE?.toLowerCase() === "true";
39 changes: 36 additions & 3 deletions packages/env/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,54 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { RedisWriteQueue } from "@prosopo/database";
import { type ProsopoConfigOutput, ScheduledTaskStatus } from "@prosopo/types";
import { Environment } from "./env.js";
import { Environment, isMaintenanceMode } from "./env.js";

export class ProviderEnvironment extends Environment {
declare config: ProsopoConfigOutput;

cleanup(): void {
this.getDb()
.cleanupScheduledTaskStatus(ScheduledTaskStatus.Running)
// Maintenance mode (or any startup that failed to bring the DB up) means
// neither Mongo collections nor Redis connections are wired. Skip both
// cleanup tasks — they'd throw synchronously and crash boot.
const dbConnected = this.db?.connected ?? false;
if (!dbConnected || isMaintenanceMode()) {
this.logger.warn(() => ({
msg: "Skipping startup cleanup — DB not connected (maintenance mode or boot-time DB failure)",
}));
return;
}

this.db
?.cleanupScheduledTaskStatus(ScheduledTaskStatus.Running)
.catch((err) => {
this.logger.error(() => ({
msg: "Failed to cleanup running scheduled tasks",
err,
data: { failedFuncName: this.cleanup.name },
}));
});

// Wipe any Redis session keys carried over from an earlier (possibly
// crashed) provider run. A stale hash → sessionId mapping can
// resurrect a Mongo-deleted session and break the next captcha
// attempt for that user+IP+sitekey.
try {
const redisConnection = this.getDb().getRedisConnection();
const writeQueue = new RedisWriteQueue(redisConnection, this.logger);
writeQueue.clearAllSessionRecords().catch((err) => {
this.logger.error(() => ({
msg: "Failed to clear Redis session records at startup",
err,
data: { failedFuncName: this.cleanup.name },
}));
});
} catch (err) {
this.logger.warn(() => ({
msg: "Skipped Redis session cleanup — no Redis connection",
err,
}));
}
}
}
Loading
Loading