From c7faae052cc17d3d2110432f5e0cefbffd388de1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 29 Oct 2026 01:50:35 +0000 Subject: [PATCH] Both enquiry limits read the same: contact-form 0.3.2 The database-backed 3-per-hour check now answers as a rate limit (verify returns { kind: 'limited' }): 429 for JSON and ?error=limited on a plain form post, which /sales turns back into "That is several enquiries in an hour...", the same wording as the guard's own limit. Before, a no-JS post over the database limit read "Invalid submission." use_case gets errorLabel "Your use case", so its messages read "Your use case is too short (10 characters at least)". Version 0.2.3. Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 4 ++-- apps/web/src/lib/salesContact.js | 4 +++- apps/web/test/sales-contact.test.js | 22 ++++++++++++++++++++-- bun.lock | 6 +++--- package.json | 2 +- 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 3480285..7d0e6fb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@rssamplifier/web", - "version": "0.2.2", + "version": "0.2.3", "private": true, "type": "module", "scripts": { @@ -11,7 +11,7 @@ }, "dependencies": { "@logicsrc/openaccess": "^0.3.1", - "@profullstack/contact-form": "0.3.0", + "@profullstack/contact-form": "0.3.2", "@profullstack/footer": "latest", "@profullstack/leaderboard": "^0.3.1", "@profullstack/notifications": "^0.1.4", diff --git a/apps/web/src/lib/salesContact.js b/apps/web/src/lib/salesContact.js index 49374b9..1d6a711 100644 --- a/apps/web/src/lib/salesContact.js +++ b/apps/web/src/lib/salesContact.js @@ -106,6 +106,7 @@ export function makeSalesContact(deps = {}) { name: 'use_case', type: 'textarea', label: 'What do you want the corpus for?', + errorLabel: 'Your use case', placeholder: 'What do you want the corpus for?\n\nWhich streams you need, roughly what scale, how often you would pull, and what it is being used to build.', rows: 5, @@ -121,7 +122,8 @@ export function makeSalesContact(deps = {}) { verify: async (_body, req) => { const since = new Date(Date.now() - WINDOW_MS).toISOString(); const recent = await store.enquiryCountFrom(client(), ipHashOf(ipOf(req)), since); - return recent >= MAX_PER_WINDOW ? TOO_MANY : true; + // Answered as a rate limit (429, ?error=limited), the same as the guard's own. + return recent >= MAX_PER_WINDOW ? { kind: 'limited', error: TOO_MANY } : true; }, persist: async (s, verdict) => { const v = s.values ?? {}; diff --git a/apps/web/test/sales-contact.test.js b/apps/web/test/sales-contact.test.js index 4ae8231..497d84a 100644 --- a/apps/web/test/sales-contact.test.js +++ b/apps/web/test/sales-contact.test.js @@ -98,7 +98,7 @@ test('a use case under ten characters is refused', async () => { assert.equal(res.status, 400); assert.deepEqual(await res.json(), { ok: false, - error: 'What do you want the corpus for? is too short (10 characters at least)', + error: 'Your use case is too short (10 characters at least)', }); assert.equal(rows.length, 0); }); @@ -106,10 +106,28 @@ test('a use case under ten characters is refused', async () => { test('the per-address limit kept in the database still applies', async () => { const { form, rows } = harness({ recent: MAX_PER_WINDOW }); const res = await form.POST(json({ ...answers, fg_token: await token() })); + assert.equal(res.status, 429); assert.equal((await res.json()).error, TOO_MANY); assert.equal(rows.length, 0); }); +test('a plain form post over the database limit comes back as ?error=limited with the hour message', async () => { + const { form, rows } = harness({ recent: MAX_PER_WINDOW }); + const body = new URLSearchParams({ ...answers, fg_token: await token() }); + const res = await form.POST( + new Request('http://localhost/api/sales/contact', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': '203.0.113.10' }, + body, + }), + ); + assert.equal(res.status, 303); + const location = new URL(res.headers.get('location'), 'http://localhost'); + assert.equal(location.searchParams.get('error'), 'limited'); + assert.equal(form.errorMessage(location.searchParams), TOO_MANY); + assert.equal(rows.length, 0); +}); + test('a plain form post is sent back to the form, with a message the page will show', async () => { const { form } = harness(); const body = new URLSearchParams({ ...answers, use_case: 'x', fg_token: await token() }); @@ -129,7 +147,7 @@ test('a plain form post is sent back to the form, with a message the page will s assert.equal(location.searchParams.get('field'), 'use_case'); assert.equal( form.errorMessage(location.searchParams), - 'What do you want the corpus for? is too short (10 characters at least)', + 'Your use case is too short (10 characters at least)', ); }); diff --git a/bun.lock b/bun.lock index d4de123..99ead77 100644 --- a/bun.lock +++ b/bun.lock @@ -35,10 +35,10 @@ }, "apps/web": { "name": "@rssamplifier/web", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@logicsrc/openaccess": "^0.3.1", - "@profullstack/contact-form": "0.3.0", + "@profullstack/contact-form": "0.3.2", "@profullstack/footer": "latest", "@profullstack/leaderboard": "^0.3.1", "@profullstack/notifications": "^0.1.4", @@ -355,7 +355,7 @@ "@peculiar/x509": ["@peculiar/x509@2.2.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.11.0", "@peculiar/asn1-csr": "^2.11.0", "@peculiar/asn1-ecc": "^2.11.0", "@peculiar/asn1-pkcs9": "^2.11.0", "@peculiar/asn1-rsa": "^2.11.0", "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "@peculiar/asn1-x509-post-quantum": "^2.11.0", "pvtsutils": "^1.3.6", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q=="], - "@profullstack/contact-form": ["@profullstack/contact-form@0.3.0", "", { "dependencies": { "@profullstack/emailer": "^1.0.3", "@profullstack/form-guard": "^0.3.0" }, "peerDependencies": { "react": ">=18.0.0" }, "optionalPeers": ["react"] }, "sha512-62O5dj6tp6y76hfLPkTQG5MALRT1TkNg/T2FhZjU4QviKf7/ft5GULaKSPzgaR938oHk8JSmPC/JiahTfrdiUA=="], + "@profullstack/contact-form": ["@profullstack/contact-form@0.3.2", "", { "dependencies": { "@profullstack/emailer": "^1.0.3", "@profullstack/form-guard": "^0.3.0" }, "peerDependencies": { "react": ">=18.0.0" }, "optionalPeers": ["react"] }, "sha512-hXzHgCFj5PAHsov6bnIai++cRaHJsoo8OsSPj1b41qDAP3WfABE87O3A7vlQ44MoRPkqV7pEk5JaSgSAGmB9Lw=="], "@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="], diff --git a/package.json b/package.json index 2b98948..5f41b1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rssamplifier.com", - "version": "0.2.2", + "version": "0.2.3", "private": true, "type": "module", "description": "Agent-first public RSS directory. Submit a URL, a list or an OPML file; every blog gets its own page.",