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
5 changes: 5 additions & 0 deletions .changeset/bright-delays-open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": patch
---

Ignore script-tag auto-open delays outside the supported timer range.
5 changes: 5 additions & 0 deletions .changeset/bright-retries-retry-after.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/server": patch
---

Honor HTTP-date values in webhook Retry-After headers.
5 changes: 5 additions & 0 deletions .changeset/calm-cats-validate-enums.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": patch
---

Ignore invalid enum values in widget script-tag attributes.
5 changes: 5 additions & 0 deletions .changeset/strict-consent-attributes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chatcops/widget': patch
---

Ignore invalid `data-wait-for-consent` values in script-tag configuration.
5 changes: 5 additions & 0 deletions .changeset/swift-bodies-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chatcops/core': patch
---

Cancel retryable HTTP response bodies before waiting and retrying.
5 changes: 5 additions & 0 deletions .changeset/tidy-messages-persist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chatcops/widget': patch
---

Validate `maxMessages` before saving conversation history, and default invalid values to 50.
5 changes: 5 additions & 0 deletions .changeset/webhook-retry-after-numbers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chatcops/server': patch
---

A webhook `Retry-After` of `-1`, or numbers with no day or month name, falls back to the usual backoff again instead of retrying at once. `Date.parse` read them as dates long past.
5 changes: 5 additions & 0 deletions .changeset/webhook-tolerance-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/server": patch
---

`verifyWebhook()` now checks `toleranceSec`: it must be a whole number of seconds, 0 or more, or the call throws a `RangeError`. Before, `NaN` and `Infinity` made the age check never reject, so a stale signature passed. The default of 300 seconds and `0` (the same second only) work as before.
5 changes: 5 additions & 0 deletions .changeset/widget-timer-delay-range.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": patch
---

`autoOpen` and `welcomeBubble.delay` passed to `init()` no longer fire at once when the number is out of range. A delay past what `setTimeout` holds (2,147,483,647 ms, about 24.8 days) waits that long, `autoOpen` with `NaN`, a negative number or a non-number doesn't auto-open, and the bubble delay falls back to its default of 3000 ms. The script tag attributes were already checked.
5 changes: 5 additions & 0 deletions .changeset/wise-containers-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chatcops/widget': patch
---

Report a clear ChatCops error for invalid inline container selectors.
1 change: 1 addition & 0 deletions .well-known/funding-manifest-urls
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
https://oss.codercops.com/funding.json
https://raw.githubusercontent.com/codercops/.github/main/funding.json
1 change: 0 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"name": "@chatcops/cli",
"version": "0.3.0",
"private": true,
"description": "The chatcops command: build a knowledge index from your site and search it",
"keywords": [
"chatcops",
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/retrieval/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export async function postWithRetries(
const detail = (await response.text().catch(() => '')).slice(0, 300);
throw new Error(`${provider} ${operation} failed with HTTP ${response.status}${detail ? `: ${detail}` : ''}`);
}
try {
await response.body?.cancel();
} catch {
// A failed cleanup should not prevent the request from being retried.
}
const wait = Number(response.headers.get('retry-after'));
await sleep(Number.isFinite(wait) && wait > 0 ? Math.min(wait, 30) * 1000 : 1000 * attempt, signal);
}
Expand Down
64 changes: 64 additions & 0 deletions packages/core/tests/retrieval/http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { postWithRetries } from '../../src/retrieval/http.js';

const post = { url: 'https://api.test/embeddings', headers: {}, body: {} };

afterEach(() => {
vi.useRealTimers();
});

describe('postWithRetries response bodies', () => {
it('cancels an intermediate retryable response body before retrying', async () => {
vi.useFakeTimers();
const events: string[] = [];
const cancel = vi.fn(() => {
events.push('cancel');
});
const intermediate = new Response(new ReadableStream({ cancel }), { status: 503 });
const success = new Response('ok');
const send = vi.fn(async () => {
if (send.mock.calls.length === 1) return intermediate;
events.push('retry');
return success;
});

const pending = postWithRetries('Provider', send, post);
await vi.advanceTimersByTimeAsync(1_000);

await expect(pending).resolves.toBe(success);
expect(send).toHaveBeenCalledTimes(2);
expect(cancel).toHaveBeenCalledOnce();
expect(events).toEqual(['cancel', 'retry']);
});

it('returns a successful response with its body available to the caller', async () => {
const success = new Response('success body');
const send = vi.fn(async () => success);

const response = await postWithRetries('Provider', send, post);

expect(response).toBe(success);
expect(response.bodyUsed).toBe(false);
await expect(response.text()).resolves.toBe('success body');
expect(send).toHaveBeenCalledOnce();
});

it('reads the final retryable response body for the existing error detail', async () => {
vi.useFakeTimers();
const cancelIntermediate = vi.fn();
const send = vi
.fn()
.mockResolvedValueOnce(new Response(new ReadableStream({ cancel: cancelIntermediate }), { status: 503 }))
.mockResolvedValueOnce(new Response(new ReadableStream({ cancel: cancelIntermediate }), { status: 503 }))
.mockResolvedValueOnce(new Response('final error detail', { status: 503 }));

const pending = expect(postWithRetries('Provider', send, post)).rejects.toThrow(
'Provider embeddings failed with HTTP 503: final error detail'
);
await vi.advanceTimersByTimeAsync(3_000);
await pending;

expect(send).toHaveBeenCalledTimes(3);
expect(cancelIntermediate).toHaveBeenCalledTimes(2);
});
});
1 change: 0 additions & 1 deletion packages/ingest/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"name": "@chatcops/ingest",
"version": "0.2.0",
"private": true,
"description": "Turn a website into a searchable ChatCops knowledge index: discover, fetch, extract, chunk and embed",
"keywords": [
"chatcops",
Expand Down
20 changes: 17 additions & 3 deletions packages/server/src/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,14 @@ function validateDeliveryOptions(config: WebhookConfig): void {
function parseRetryAfter(value: string | null): number | undefined {
if (!value) return undefined;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds < 0) return undefined;
return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS);
if (Number.isFinite(seconds)) return seconds >= 0 ? Math.min(seconds * 1000, MAX_RETRY_AFTER_MS) : undefined;
// An HTTP-date has day and month names. Without the check, Date.parse reads
// values like "120.5" or "-1 day" as dates long past, and the retry wouldn't wait.
if (!/[a-z]/i.test(value)) return undefined;

const retryAt = Date.parse(value);
if (Number.isNaN(retryAt)) return undefined;
return Math.min(Math.max(retryAt - Date.now(), 0), MAX_RETRY_AFTER_MS);
}

async function hmacHex(secret: string, message: string): Promise<string> {
Expand All @@ -207,7 +213,10 @@ export async function signWebhook(body: string, secret: string, timestampSec = M
/**
* Checks a delivery on the receiving side. Pass the raw request body exactly
* as received (not re-serialized JSON) and the `X-ChatCops-Signature` header.
* Returns false for a bad signature or one older than `toleranceSec`.
* Returns false for a bad signature or one more than `toleranceSec` seconds
* from now, in the past or the future (default 300; 0 allows only the same
* second). Throws a RangeError when `toleranceSec` is not a whole number of
* seconds, 0 or more.
*/
export async function verifyWebhook(options: {
body: string;
Expand All @@ -217,6 +226,11 @@ export async function verifyWebhook(options: {
now?: number;
}): Promise<boolean> {
const { body, signature, secret, toleranceSec = DEFAULT_SIGNATURE_TOLERANCE_SEC } = options;
// A setting, not input: a wrong one throws instead of returning false, so it can't pass for a bad signature.
// NaN and Infinity would make the age check below never reject anything.
if (!Number.isSafeInteger(toleranceSec) || toleranceSec < 0) {
throw new RangeError(`verifyWebhook: toleranceSec must be a whole number of seconds, 0 or more (got ${String(toleranceSec)})`);
}
if (!signature) return false;

const parts = Object.fromEntries(
Expand Down
85 changes: 85 additions & 0 deletions packages/server/tests/webhook-delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,91 @@ describe('webhook retries', () => {
expect(calls).toHaveLength(3);
});

it('parses HTTP-date Retry-After values and caps their delay at 10 seconds', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-31T14:00:00.000Z'));
const calls = captureFetch([
new Response('', {
status: 429,
headers: { 'Retry-After': new Date(Date.now() + 5_000).toUTCString() },
}),
new Response('', {
status: 429,
headers: { 'Retry-After': new Date(Date.now() + 30_000).toUTCString() },
}),
new Response('', { status: 200 }),
]);
const dispatcher = new WebhookDispatcher([{ url: 'https://hooks.test/date', events: ['*'] }]);

const done = dispatcher.dispatch('lead:captured', {});
await vi.advanceTimersByTimeAsync(4_999);
expect(calls).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
expect(calls).toHaveLength(2);
await vi.advanceTimersByTimeAsync(9_999);
expect(calls).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1);
await done;
expect(calls).toHaveLength(3);
});

it('waits for a fractional Retry-After in seconds', async () => {
vi.useFakeTimers();
const calls = captureFetch([
new Response('', { status: 429, headers: { 'Retry-After': '1.5' } }),
new Response('', { status: 200 }),
]);
const dispatcher = new WebhookDispatcher([{ url: 'https://hooks.test/fraction', events: ['*'] }]);

const done = dispatcher.dispatch('lead:captured', {});
await vi.advanceTimersByTimeAsync(1_499);
expect(calls).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
await done;
expect(calls).toHaveLength(2);
});

it('retries immediately for a past HTTP-date Retry-After value', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-31T14:00:00.000Z'));
const calls = captureFetch([
new Response('', {
status: 429,
headers: { 'Retry-After': 'Sat, 31 Oct 2026 13:59:59 GMT' },
}),
new Response('', { status: 200 }),
]);
const dispatcher = new WebhookDispatcher([{ url: 'https://hooks.test/past-date', events: ['*'] }]);

const done = dispatcher.dispatch('lead:captured', {});
await vi.advanceTimersByTimeAsync(0);
await done;
expect(calls).toHaveLength(2);
});

it.each([
['invalid', 'not a valid HTTP date'],
['missing', null],
// Both of these are dates long past to Date.parse, which would retry at once.
['negative', '-1'],
['numbers with no day or month name', '1 2'],
])('%s Retry-After uses the exponential fallback', async (_label, retryAfter) => {
vi.useFakeTimers();
const headers = retryAfter ? { 'Retry-After': retryAfter } : {};
const calls = captureFetch([
new Response('', { status: 429, headers }),
new Response('', { status: 200 }),
]);
const dispatcher = new WebhookDispatcher([{ url: 'https://hooks.test/fallback', events: ['*'] }]);

const done = dispatcher.dispatch('lead:captured', {});
await vi.advanceTimersByTimeAsync(999);
expect(calls).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
await done;
expect(calls).toHaveLength(2);
});

it("doesn't retry other client errors, and logs the failure without the body or secret", async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const calls = captureFetch([new Response('', { status: 400 })]);
Expand Down
81 changes: 80 additions & 1 deletion packages/server/tests/webhook.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WebhookDispatcher } from '../src/webhook.js';
import { DEFAULT_SIGNATURE_TOLERANCE_SEC, WebhookDispatcher, signWebhook, verifyWebhook } from '../src/webhook.js';

describe('WebhookDispatcher', () => {
beforeEach(() => {
Expand Down Expand Up @@ -110,3 +110,82 @@ describe('WebhookDispatcher', () => {
expect(callCount).toBe(2);
});
});

describe('verifyWebhook toleranceSec', () => {
const body = JSON.stringify({ id: 'evt_1', type: 'lead:captured', data: { email: 'a@b.test' } });
const secret = 'shh';
const nowSec = 1_800_000_000;
const now = nowSec * 1000;
const verify = (toleranceSec: number | undefined, ageSec: number) =>
signWebhook(body, secret, nowSec - ageSec).then((signature) => verifyWebhook({ body, signature, secret, now, ...(toleranceSec === undefined ? {} : { toleranceSec }) }));

it('rejects a tolerance that is not a finite, non-negative whole number, with a RangeError that says which', async () => {
const signature = await signWebhook(body, secret, nowSec);
for (const toleranceSec of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, -1, -300, 0.5, 300.5, 2 ** 53, Number.MAX_VALUE]) {
const call = verifyWebhook({ body, signature, secret, now, toleranceSec });
await expect(call, String(toleranceSec)).rejects.toBeInstanceOf(RangeError);
await expect(call, String(toleranceSec)).rejects.toThrow('toleranceSec must be a whole number of seconds, 0 or more');
}
});

it('rejects values that are not numbers at all', async () => {
const signature = await signWebhook(body, secret, nowSec);
for (const toleranceSec of ['300', null, {}, true, [], 10n]) {
await expect(verifyWebhook({ body, signature, secret, now, toleranceSec } as never), String(toleranceSec)).rejects.toBeInstanceOf(RangeError);
}
});

it('says what it got in the message', async () => {
const signature = await signWebhook(body, secret, nowSec);
await expect(verifyWebhook({ body, signature, secret, now, toleranceSec: Number.NaN })).rejects.toThrow('got NaN');
await expect(verifyWebhook({ body, signature, secret, now, toleranceSec: -5 })).rejects.toThrow('got -5');
});

it('checks the tolerance before the signature, so a bad setting is loud even for a bad signature or none', async () => {
await expect(verifyWebhook({ body, signature: null, secret, now, toleranceSec: Number.NaN })).rejects.toBeInstanceOf(RangeError);
await expect(verifyWebhook({ body, signature: 'garbage', secret, now, toleranceSec: Number.POSITIVE_INFINITY })).rejects.toBeInstanceOf(RangeError);
});

it('keeps the default of 300 seconds', async () => {
expect(DEFAULT_SIGNATURE_TOLERANCE_SEC).toBe(300);
expect(await verify(undefined, 0)).toBe(true);
expect(await verify(undefined, 300)).toBe(true);
expect(await verify(undefined, 301)).toBe(false);
expect(await verify(undefined, -300)).toBe(true);
expect(await verify(undefined, -301)).toBe(false);
});

it('allows 0, which takes only the signature made in the same second', async () => {
expect(await verify(0, 0)).toBe(true);
expect(await verify(0, 1)).toBe(false);
expect(await verify(0, -1)).toBe(false);
});

it('uses a valid custom tolerance, in both directions', async () => {
expect(await verify(60, 60)).toBe(true);
expect(await verify(60, 61)).toBe(false);
expect(await verify(60, -60)).toBe(true);
expect(await verify(60, -61)).toBe(false);
expect(await verify(3600, 3600)).toBe(true);
expect(await verify(3600, 3601)).toBe(false);
});

it('accepts the largest whole number it can count', async () => {
expect(await verify(Number.MAX_SAFE_INTEGER, 1_000_000)).toBe(true);
});

it('still rejects expired and future-dated signatures outside the tolerance, and a bad signature inside it', async () => {
expect(await verify(300, 3600)).toBe(false);
expect(await verify(300, -3600)).toBe(false);
const signature = await signWebhook(body, secret, nowSec);
expect(await verifyWebhook({ body, signature, secret: 'other', now, toleranceSec: 300 })).toBe(false);
expect(await verifyWebhook({ body: `${body} `, signature, secret, now, toleranceSec: 300 })).toBe(false);
});

it('no longer lets NaN or Infinity switch the freshness check off', async () => {
const stale = await signWebhook(body, secret, nowSec - 86_400);
for (const toleranceSec of [Number.NaN, Number.POSITIVE_INFINITY]) {
await expect(verifyWebhook({ body, signature: stale, secret, now, toleranceSec })).rejects.toBeInstanceOf(RangeError);
}
});
});
1 change: 0 additions & 1 deletion packages/store-cloudflare/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"name": "@chatcops/store-cloudflare",
"version": "0.3.0",
"private": true,
"description": "Cloudflare D1, KV and Durable Objects storage for ChatCops",
"keywords": [
"chatcops",
Expand Down
1 change: 0 additions & 1 deletion packages/store-postgres/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"name": "@chatcops/store-postgres",
"version": "0.3.0",
"private": true,
"description": "Postgres storage for ChatCops: conversations, counters, keys and analytics events",
"keywords": [
"chatcops",
Expand Down
Loading