Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ One flat config at the repo root (`eslint.config.mjs`) covers every package —

`@typescript-eslint/no-explicit-any` is a **warning** under a per-package budget, set in each package's lint script (`eslint src --max-warnings N`). CI fails if the count grows, so a new `any` needs either a real type or a deliberate decision to raise the number. Lower it when you remove one.

Four **type-aware** rules run on `src` (not on tests): `no-floating-promises`, `no-misused-promises`, `await-thenable`, `require-await`. They need a TypeScript program and are slower, so the set is deliberately small — these catch things `tsc` does not, and the rest of `recommendedTypeChecked` mostly duplicates `strict` at the cost of a large style backlog.

`no-floating-promises` is the one that earns its keep here. This SDK does a lot of deliberate fire-and-forget — violation reporting, honeytoken derivation, directory warmup — and an accidental one looks identical to an intentional one. Mark the deliberate ones with `void`, and say in a comment why the rejection is safe to drop.

Format code:

```bash
Expand Down
30 changes: 30 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,36 @@ export default tseslint.config(
},
},

// Type-aware rules, on the source only.
//
// These need a TypeScript program, which costs real time — so they are scoped
// to the rules that actually catch things `tsc` does not. The headline is
// no-floating-promises: this SDK does a lot of deliberate fire-and-forget
// (violation reporting, honeytoken derivation, directory warmup) where the
// intentional ones are marked `void` and an accidental one would look
// identical. A detection that silently never reported is the exact failure
// this catches.
//
// The broad `recommendedTypeChecked` preset is deliberately NOT used: most of
// it duplicates what `strict` already enforces, at the cost of a much slower
// lint and a large backlog of findings that are style rather than defects.
{
files: ['**/src/**/*.ts'],
ignores: ['**/*.test.ts', '**/*.spec.ts'],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/require-await': 'error',
},
},

{
files: ['**/*.test.ts', '**/*.spec.ts'],
languageOptions: {
Expand Down
5 changes: 4 additions & 1 deletion packages/client/src/collectors/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,10 @@ export class EnvironmentalCollector {
state: audioCtx.state,
baseLatency: audioCtx.baseLatency
};
audioCtx.close();
// Not awaited (the info is already gathered) but the rejection has to go
// somewhere: `void` alone would leave an unhandled rejection logged in the
// user's console, and the try/catch above does not cover it.
audioCtx.close().catch(() => {});
return info;
} catch {
return { supported: false, error: true };
Expand Down
46 changes: 29 additions & 17 deletions packages/client/src/invisible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,12 @@ export class InvisibleSession {
}

private _attachToForms(): void {
document.addEventListener('submit', async (e) => {
// Deliberately a SYNCHRONOUS listener. `e.preventDefault()` below only
// works because nothing has awaited yet — once the handler yields, the
// browser has already submitted the form and cancelling is a no-op. An
// async listener made that a one-line change away from silently breaking,
// with no test that would notice.
document.addEventListener('submit', (e) => {
const form = e.target as HTMLFormElement;
if (form.dataset.webdecoyIgnore) return;

Expand All @@ -122,28 +127,35 @@ export class InvisibleSession {

if (!this.lastScore || Date.now() - this.lastScore.timestamp > 60000) {
e.preventDefault();

try {
const result = await this.execute(form.dataset.webdecoyAction || 'form_submit');
tokenField.value = result.token || '';

if (result.success) {
form.submit();
} else {
document.dispatchEvent(
new CustomEvent('webdecoy:blocked', { detail: { score: result.score, form } }),
);
}
} catch (error) {
console.error('WebDecoy captcha error:', error);
form.submit(); // Fail open
}
void this._scoreThenSubmit(form, tokenField);
} else {
tokenField.value = this.lastScore.token || '';
}
});
}

/** Score the session, then resubmit the form the listener cancelled. */
private async _scoreThenSubmit(
form: HTMLFormElement,
tokenField: HTMLInputElement,
): Promise<void> {
try {
const result = await this.execute(form.dataset.webdecoyAction || 'form_submit');
tokenField.value = result.token || '';

if (result.success) {
form.submit();
} else {
document.dispatchEvent(
new CustomEvent('webdecoy:blocked', { detail: { score: result.score, form } }),
);
}
} catch (error) {
console.error('WebDecoy captcha error:', error);
form.submit(); // Fail open
}
}

async execute(action = ''): Promise<VerifyResponse> {
const elapsed = Date.now() - this.startTime;
if (elapsed < this.options.minCollectionTime) {
Expand Down
12 changes: 10 additions & 2 deletions packages/express/src/captcha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,15 @@ function normalizeQuery(query: Request['query']): Record<string, string | undefi
export function webdecoyCaptcha(options?: ExpressCaptchaOptions): RequestHandler {
const endpoints = createCaptchaEndpoints(options);

return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
return (req: Request, res: Response, next: NextFunction): void => {
// Express 4 does not catch a rejected promise from a handler, so an async
// handler that throws leaves the request hanging until the client times out
// and logs an unhandled rejection instead of a 500. Kept synchronous, with
// the rejection routed to the error middleware explicitly.
void handle(req, res, next).catch(next);
};

async function handle(req: Request, res: Response, next: NextFunction): Promise<void> {
const result = await endpoints.handle({
method: req.method,
pathname: req.path,
Expand All @@ -80,5 +88,5 @@ export function webdecoyCaptcha(options?: ExpressCaptchaOptions): RequestHandler
res.status(result.status);
for (const [k, v] of Object.entries(result.headers)) res.setHeader(k, v);
res.json(result.body);
};
}
}
3 changes: 3 additions & 0 deletions packages/fastify/src/captcha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import fp from 'fastify-plugin';
import { createCaptchaEndpoints, type CaptchaEndpointsOptions } from '@webdecoy/node';

// fastify-plugin's async contract: the signature is what marks this a plugin,
// not the body, so there is nothing here to await.
// eslint-disable-next-line @typescript-eslint/require-await
async function plugin(fastify: FastifyInstance, options: CaptchaEndpointsOptions): Promise<void> {
const endpoints = createCaptchaEndpoints(options);

Expand Down
5 changes: 4 additions & 1 deletion packages/webdecoy/src/violation-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export class ViolationReporter {
this.debug = config.debug ?? false;

const flushInterval = config.flushInterval ?? 5000;
this.flushTimer = setInterval(() => this.flush(), flushInterval);
// flush() catches everything internally and never rejects, so `void` is the
// whole handling. Said out loud because a timer whose callback rejects
// keeps firing and every tick adds another unhandled rejection.
this.flushTimer = setInterval(() => void this.flush(), flushInterval);
if (this.flushTimer.unref) {
this.flushTimer.unref();
}
Expand Down
Loading