diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a5b801..e466b07 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/eslint.config.mjs b/eslint.config.mjs index 5f99a7e..9e7a653 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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: { diff --git a/packages/client/src/collectors/environment.ts b/packages/client/src/collectors/environment.ts index 86b4d53..1960ca7 100644 --- a/packages/client/src/collectors/environment.ts +++ b/packages/client/src/collectors/environment.ts @@ -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 }; diff --git a/packages/client/src/invisible.ts b/packages/client/src/invisible.ts index c62611a..bdbb7b0 100644 --- a/packages/client/src/invisible.ts +++ b/packages/client/src/invisible.ts @@ -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; @@ -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 { + 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 { const elapsed = Date.now() - this.startTime; if (elapsed < this.options.minCollectionTime) { diff --git a/packages/express/src/captcha.ts b/packages/express/src/captcha.ts index 84871bd..c0be842 100644 --- a/packages/express/src/captcha.ts +++ b/packages/express/src/captcha.ts @@ -62,7 +62,15 @@ function normalizeQuery(query: Request['query']): Record => { + 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 { const result = await endpoints.handle({ method: req.method, pathname: req.path, @@ -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); - }; + } } diff --git a/packages/fastify/src/captcha.ts b/packages/fastify/src/captcha.ts index f0ad83b..df7c4fd 100644 --- a/packages/fastify/src/captcha.ts +++ b/packages/fastify/src/captcha.ts @@ -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 { const endpoints = createCaptchaEndpoints(options); diff --git a/packages/webdecoy/src/violation-reporter.ts b/packages/webdecoy/src/violation-reporter.ts index 0076d5b..20771c4 100644 --- a/packages/webdecoy/src/violation-reporter.ts +++ b/packages/webdecoy/src/violation-reporter.ts @@ -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(); }