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/sessions-by-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/server": major
---

Session tokens are required by default. A server without `sessionSecrets` refuses to start unless it sets `allowAnonymous: true`, so a chat can't be left open to guessed or leaked conversation ids by accident. `requireSession` is removed: `requireSession: false` is now `allowAnonymous: true` next to `sessionSecrets`, and `true` was already the default. The widget fetches tokens itself from 0.6, but it asks `<chat url>/session`, so with Express mount the handler with `app.use('/chat', ...)`, and on Vercel or Next.js add a `/session` route that re-exports the chat route. `chatcops test` runs the server in process without needing tokens. See [Upgrading to 1.0](https://chat.codercops.com/getting-started/upgrading-to-1/).
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ import { chatcopsMiddleware } from '@chatcops/server';
const app = express();
app.use(express.json());

app.all('/chat', chatcopsMiddleware({
app.use('/chat', chatcopsMiddleware({
provider: {
type: 'claude',
apiKey: process.env.ANTHROPIC_API_KEY,
},
systemPrompt: 'You are a helpful assistant for our website.',
sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET], // openssl rand -base64 32
}));

app.listen(3001);
Expand Down
2 changes: 1 addition & 1 deletion api/server.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export interface ChatCopsServerConfig {
clientIp?: ClientIpResolver;
sessionSecrets?: string[];
sessionTtlSec?: number;
requireSession?: boolean;
allowAnonymous?: boolean;
turnstile?: TurnstileConfig;
identity?: {
secret: string;
Expand Down
3 changes: 2 additions & 1 deletion e2e/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ function chatEndpoint(overrides = {}) {
tools: [slowLookup],
toolTimeoutMs: 30_000,
budgets: false,
allowAnonymous: true,
...overrides,
};
const handler = createChatHandler(config);
Expand All @@ -187,7 +188,7 @@ function chatEndpoint(overrides = {}) {
const endpoints = {
'/api/chat': chatEndpoint(),
// Session tokens on, for the session spec.
'/api/secure-chat': chatEndpoint({ sessionSecrets: ['an-e2e-session-secret-of-32-bytes-or-more'] }),
'/api/secure-chat': chatEndpoint({ sessionSecrets: ['an-e2e-session-secret-of-32-bytes-or-more'], allowAnonymous: false }),
};

/** Runs a Fetch API handler for a Node request. */
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/test/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ async function runOnce(
const { spend } = options;
const context = { fixture: path, ...(options.judge ? { judge: options.judge } : {}) };
// Usage on `finish` is what usage assertions read.
const handler = createChatHandler({ ...server, exposeUsage: true, storage: memoryStorage(), tools: withMocks(server.tools ?? [], test.mocks?.tools) });
// In process, so there's no browser to get session tokens for: requests go straight to the handler.
const handler = createChatHandler({ ...server, allowAnonymous: true, exposeUsage: true, storage: memoryStorage(), tools: withMocks(server.tools ?? [], test.mocks?.tools) });
const conversationId = `eval-${randomUUID()}`;
const conversation: TurnResult[] = [];

Expand Down
16 changes: 13 additions & 3 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ import { chatcopsMiddleware } from '@chatcops/server';
const app = express();
app.use(express.json());

app.all('/chat', chatcopsMiddleware({
// app.use, so /chat/session, where the widget gets its session token, reaches it too.
app.use('/chat', chatcopsMiddleware({
provider: {
type: 'claude',
apiKey: process.env.ANTHROPIC_API_KEY,
},
systemPrompt: 'You are a helpful assistant for our website.',
allowedOrigins: ['*'],
sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET], // openssl rand -base64 32
}));

app.listen(3001);
Expand All @@ -40,13 +42,16 @@ const handler = chatcopsVercelHandler({
provider: { type: 'openai', apiKey: process.env.OPENAI_API_KEY },
systemPrompt: 'You are a helpful assistant.',
allowedOrigins: ['*'],
sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET],
});

export const POST = (req: Request) => handler(req);
export const OPTIONS = (req: Request) => handler(req);
export const config = { runtime: 'edge' };
```

Export the same handler from the `/session` route next to it (`app/api/chat/session/route.ts` for a chat at `/api/chat`), where the widget gets its session token.

### Cloudflare Workers

```typescript
Expand All @@ -58,6 +63,7 @@ function getHandler(env: Env) {
provider: { type: 'claude', apiKey: env.ANTHROPIC_API_KEY },
systemPrompt: 'You are a helpful assistant.',
allowedOrigins: ['*'],
sessionSecrets: [env.CHATCOPS_SESSION_SECRET],
});
return handler;
}
Expand Down Expand Up @@ -112,7 +118,10 @@ const handler = createChatHandler({
analytics: true,

// Conversation storage (optional, default: in-memory)
conversationStore: new RedisConversationStore(redisClient),
storage: { conversations: new RedisConversationStore(redisClient) },

// Session tokens: required unless allowAnonymous: true
sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET],

// CORS origin (optional, default: '*')
allowedOrigins: ['*'],
Expand Down Expand Up @@ -206,7 +215,8 @@ Pass the store when initializing the handler:
const handler = createChatHandler({
provider: { type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY },
systemPrompt: 'You are a helpful assistant.',
conversationStore: new RedisConversationStore(redisClient),
storage: { conversations: new RedisConversationStore(redisClient) },
allowAnonymous: true,
});
```

Expand Down
10 changes: 6 additions & 4 deletions packages/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,13 @@ export interface ChatCopsServerConfig {
/** How long a session token lasts, in seconds. Defaults to 900. */
sessionTtlSec?: number;
/**
* Refuse chat requests without a session token (401 `session_required`).
* Defaults to true when `sessionSecrets` is set. Set false while widgets
* older than 0.6, which don't send tokens, are still out there.
* Accept chat requests without a session token. Off by default: the
* server needs `sessionSecrets`, and each chat request needs a token from
* `<chat url>/session`, which the widget gets by itself. Set true for a
* server without `sessionSecrets`, or, with them, while widgets older than
* 0.6 (which don't send tokens) are still out there.
*/
requireSession?: boolean;
allowAnonymous?: boolean;
/**
* Cloudflare Turnstile when a session token is issued: `/session` needs a
* Turnstile token and checks it with Cloudflare before signing. Refreshing
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/examples/cloudflare-worker.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Example: Cloudflare Worker
*
* Set env vars: ANTHROPIC_API_KEY
* Set env vars: ANTHROPIC_API_KEY, and CHATCOPS_SESSION_SECRET (`openssl rand -base64 32`)
*/
import { chatcopsCloudflareHandler } from '@chatcops/server';

Expand All @@ -17,6 +17,8 @@ function getHandler(env: Record<string, string>) {
},
systemPrompt: 'You are a helpful assistant.',
allowedOrigins: ['*'],
// Signs the session tokens the widget fetches before it chats.
sessionSecrets: [env.CHATCOPS_SESSION_SECRET],
});
return handler;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/examples/express-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ app.all('/chat', chatcopsMiddleware({
Be friendly, concise, and helpful. If a user wants to get in touch,
ask for their name, email, and project details to capture as a lead.`,
allowedOrigins: ['*'],
// Signs the session tokens the widget fetches before it chats (`openssl rand -base64 32`).
sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET ?? ''],
rateLimit: { maxRequests: 30, windowMs: 60_000 },
analytics: true,
}));
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/examples/vercel-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ export default chatcopsVercelHandler({
},
systemPrompt: 'You are a helpful assistant.',
allowedOrigins: ['*'],
// Signs the session tokens the widget fetches before it chats (`openssl rand -base64 32`).
sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET ?? ''],
});
2 changes: 2 additions & 0 deletions packages/server/src/removed-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const UPGRADE_GUIDE = 'https://chat.codercops.com/getting-started/upgrading-to-1
const REMOVED: Record<string, string> = {
cors: "`cors` was removed in 1.0. Use `allowedOrigins: ['https://your.site']`, which also takes several sites and wildcards.",
conversationStore: '`conversationStore` was removed in 1.0. Use `storage: { conversations: yourStore }`, or a store package.',
requireSession:
'`requireSession` was removed in 1.0: session tokens are required by default. To accept chat requests without one (what `requireSession: false` did), set `allowAnonymous: true`.',
i18n: "`i18n` was removed in 1.0. It did nothing since 0.6: set the chat's text with the widget's `strings` option.",
};

Expand Down
10 changes: 7 additions & 3 deletions packages/server/src/security/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface SessionConfig {
logger?: Logger;
sessionSecrets?: string[];
sessionTtlSec?: number;
requireSession?: boolean;
allowAnonymous?: boolean;
turnstile?: TurnstileConfig;
identity?: { secret: string };
}
Expand Down Expand Up @@ -63,7 +63,11 @@ export function createSessions(
): Sessions | undefined {
const secrets = config.sessionSecrets;
if (secrets === undefined) {
if (config.requireSession) throw new Error('`requireSession` needs `sessionSecrets` to sign tokens with.');
if (!config.allowAnonymous) {
throw new Error(
'Chat requests need a session token unless `allowAnonymous: true` is set. Set `sessionSecrets` (generate one with `openssl rand -base64 32`, and keep it on the server); the widget fetches tokens by itself. Or set `allowAnonymous: true` to accept chat without them. See https://chat.codercops.com/server/setup/#session-tokens'
);
}
if (config.turnstile) throw new Error('`turnstile` is checked when a session token is issued, so it needs `sessionSecrets`.');
if (config.identity) throw new Error('`identity` goes into session tokens, so it needs `sessionSecrets`.');
return undefined;
Expand All @@ -90,7 +94,7 @@ export function createSessions(
let identityKey: Promise<CryptoKey> | undefined;
const getIdentityKey = () => (identityKey ??= importSessionKey(identitySecret as string));
const nowSec = () => Math.floor(now() / 1000);
const required = config.requireSession ?? true;
const required = !config.allowAnonymous;

return {
required,
Expand Down
7 changes: 4 additions & 3 deletions packages/server/tests/abort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe('handleChat with a signal', () => {
it('passes the signal to the provider, stops quietly and saves the partial reply as aborted', async () => {
const store = new MemoryStore();
const { handleChat, cleanup } = createChatHandler({
allowAnonymous: true,
provider: { type: 'claude', apiKey: 'k' },
systemPrompt: 'Test',
storage: { conversations: store },
Expand All @@ -84,7 +85,7 @@ describe('handleChat with a signal', () => {

describe('adapters stop the provider when the visitor disconnects', () => {
it('Vercel: cancelling the response stream aborts the reply', async () => {
const handler = chatcopsVercelHandler({ provider: { type: 'claude', apiKey: 'k' }, systemPrompt: 'Test' });
const handler = chatcopsVercelHandler({ allowAnonymous: true, provider: { type: 'claude', apiKey: 'k' }, systemPrompt: 'Test' });
const res = await handler(new Request('http://localhost/api/chat', { method: 'POST', body: JSON.stringify(body) }));
const reader = res.body!.getReader();
const first = new TextDecoder().decode((await reader.read()).value);
Expand All @@ -95,7 +96,7 @@ describe('adapters stop the provider when the visitor disconnects', () => {
});

it('Cloudflare: a failed write after the visitor left aborts the reply', async () => {
const handler = chatcopsCloudflareHandler({ provider: { type: 'claude', apiKey: 'k' }, systemPrompt: 'Test' });
const handler = chatcopsCloudflareHandler({ allowAnonymous: true, provider: { type: 'claude', apiKey: 'k' }, systemPrompt: 'Test' });
const waitUntil = vi.fn();
const res = await handler(
new Request('http://localhost/api/chat', { method: 'POST', body: JSON.stringify(body) }),
Expand Down Expand Up @@ -138,7 +139,7 @@ describe('adapters stop the provider when the visitor disconnects', () => {
};
const req = { method: 'POST', body, ip: '127.0.0.1', socket: {} };

await chatcopsMiddleware({ provider: { type: 'claude', apiKey: 'k' }, systemPrompt: 'Test' })(
await chatcopsMiddleware({ allowAnonymous: true, provider: { type: 'claude', apiKey: 'k' }, systemPrompt: 'Test' })(
req as unknown as ExpressRequest,
res as unknown as ExpressResponse,
vi.fn() as unknown as NextFunction
Expand Down
6 changes: 6 additions & 0 deletions packages/server/tests/adapters/cloudflare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createChatHandler } from '../../src/handler.js';
import { chatcopsCloudflareHandler } from '../../src/adapters/cloudflare.js';

const config = {
allowAnonymous: true,
provider: { type: 'claude' as const, apiKey: 'test-key' },
systemPrompt: 'Test',
allowedOrigins: ['*'],
Expand All @@ -32,6 +33,7 @@ describe('chatcopsCloudflareHandler', () => {

it('returns Access-Control-Allow-Origin: * for 405 when cors is not configured', async () => {
const handlerWithoutCors = chatcopsCloudflareHandler({
allowAnonymous: true,
provider: { type: 'claude' as const, apiKey: 'test-key' },
systemPrompt: 'Test',
});
Expand All @@ -46,6 +48,7 @@ describe('chatcopsCloudflareHandler', () => {

it('returns custom Access-Control-Allow-Origin for 405 when cors is configured', async () => {
const handlerWithCustomCors = chatcopsCloudflareHandler({
allowAnonymous: true,
provider: { type: 'claude' as const, apiKey: 'test-key' },
systemPrompt: 'Test',
allowedOrigins: ['https://example.com'],
Expand All @@ -68,6 +71,7 @@ describe('chatcopsCloudflareHandler', () => {

it('returns Access-Control-Allow-Origin: * for OPTIONS when cors is not configured', async () => {
const handlerWithoutCors = chatcopsCloudflareHandler({
allowAnonymous: true,
provider: { type: 'claude' as const, apiKey: 'test-key' },
systemPrompt: 'Test',
});
Expand All @@ -92,6 +96,7 @@ describe('chatcopsCloudflareHandler', () => {

it('returns Access-Control-Allow-Origin: * for 400 when cors is not configured', async () => {
const handlerWithoutCors = chatcopsCloudflareHandler({
allowAnonymous: true,
provider: { type: 'claude' as const, apiKey: 'test-key' },
systemPrompt: 'Test',
});
Expand All @@ -110,6 +115,7 @@ describe('chatcopsCloudflareHandler', () => {

it('returns custom Access-Control-Allow-Origin for 400 when cors is configured', async () => {
const handlerWithCustomCors = chatcopsCloudflareHandler({
allowAnonymous: true,
provider: { type: 'claude' as const, apiKey: 'test-key' },
systemPrompt: 'Test',
allowedOrigins: ['https://example.com'],
Expand Down
11 changes: 11 additions & 0 deletions packages/server/tests/adapters/express.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ describe('express analytics sharing', () => {
}));

const config: ChatCopsServerConfig = {
allowAnonymous: true,
provider: { type: 'claude', apiKey: 'test-key' },
systemPrompt: 'Test',
allowedOrigins: ['*'],
Expand Down Expand Up @@ -161,6 +162,7 @@ describe('express analytics sharing', () => {
}));

const middleware = chatcopsMiddleware({
allowAnonymous: true,
provider: { type: 'claude', apiKey: 'test-key' },
systemPrompt: 'Test',
});
Expand Down Expand Up @@ -194,6 +196,7 @@ describe('express analytics sharing', () => {
}));

const middleware = chatcopsMiddleware({
allowAnonymous: true,
provider: { type: 'claude', apiKey: 'test-key' },
systemPrompt: 'Test',
});
Expand All @@ -210,6 +213,7 @@ describe('express analytics sharing', () => {

it('returns 204 with CORS headers for OPTIONS requests', async () => {
const config: ChatCopsServerConfig = {
allowAnonymous: true,
provider: { type: 'claude', apiKey: 'test-key' },
systemPrompt: 'Test',
allowedOrigins: ['*'],
Expand All @@ -236,6 +240,7 @@ describe('express analytics sharing', () => {

it('returns 405 JSON for GET requests', async () => {
const config: ChatCopsServerConfig = {
allowAnonymous: true,
provider: { type: 'claude', apiKey: 'test-key' },
systemPrompt: 'Test',
allowedOrigins: ['*'],
Expand All @@ -259,6 +264,7 @@ describe('express analytics sharing', () => {

it('returns Access-Control-Allow-Origin: * for 405 when cors is not configured', async () => {
const config: ChatCopsServerConfig = {
allowAnonymous: true,
provider: { type: 'claude', apiKey: 'test-key' },
systemPrompt: 'Test',
};
Expand All @@ -280,6 +286,7 @@ describe('express analytics sharing', () => {

it('returns custom Access-Control-Allow-Origin for 405 when cors is configured', async () => {
const config: ChatCopsServerConfig = {
allowAnonymous: true,
provider: { type: 'claude', apiKey: 'test-key' },
systemPrompt: 'Test',
allowedOrigins: ['https://example.com'],
Expand All @@ -302,6 +309,7 @@ describe('express analytics sharing', () => {

it('streams chunks as SSE data, writes DONE, and ends the response', async () => {
const config: ChatCopsServerConfig = {
allowAnonymous: true,
provider: { type: 'claude', apiKey: 'test-key' },
systemPrompt: 'Test',
allowedOrigins: ['*'],
Expand Down Expand Up @@ -332,6 +340,7 @@ describe('express analytics sharing', () => {

it('reports middleware traffic through chatcopsAnalyticsHandler', async () => {
const config: ChatCopsServerConfig = {
allowAnonymous: true,
provider: { type: 'claude', apiKey: 'test-key' },
systemPrompt: 'Test',
allowedOrigins: ['*'],
Expand Down Expand Up @@ -363,6 +372,7 @@ describe('express analytics sharing', () => {
it('warns once across several requests when analyticsToken is not configured', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const config: ChatCopsServerConfig = {
allowAnonymous: true,
provider: { type: 'claude', apiKey: 'test-key' },
systemPrompt: 'Test',
analytics: true,
Expand All @@ -386,6 +396,7 @@ describe('express analytics sharing', () => {
it('requires a bearer token when analyticsToken is configured', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const config: ChatCopsServerConfig = {
allowAnonymous: true,
provider: { type: 'claude', apiKey: 'test-key' },
systemPrompt: 'Test',
analytics: true,
Expand Down
Loading
Loading