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
17 changes: 14 additions & 3 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,32 @@ import * as http from 'node:http';
import { createAgent, createMockProvider, httpChannel, mountChannels } from '@lousho/build-ai-agent';

const agent = createAgent({ instructions: 'You are helpful.', provider: createMockProvider() });
const channels = mountChannels(agent, [httpChannel()]);
const channels = mountChannels(agent, [
httpChannel({ verify: async (req) => req.headers.authorization === `Bearer ${process.env.API_TOKEN}` }),
]);

const server = http.createServer((req, res) => {
void channels(req, res).then((handled) => {
if (!handled) res.writeHead(404).end();
});
});
server.listen(3000);
server.listen(3000, '127.0.0.1');
```

```bash
curl -s localhost:3000/channels/http -H 'Content-Type: application/json' -d '{"sessionKey":"user-42","input":"Hi, I am Ali"}'
curl -s localhost:3000/channels/http -H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/json' -d '{"sessionKey":"user-42","input":"Hi, I am Ali"}'
# {"sessionId":"http_user-42-...","text":"...","finishReason":"stop"}
```

### Securing the http channel

Sessions are keyed by the `sessionKey` the caller sends, so an `httpChannel()`
without `verify` lets anyone who can reach the port read or continue another
user's conversation by guessing a key. It logs a warning once when created.
Always pass `verify` (a bearer-token check, as above, or your own session
check), bind to `127.0.0.1` unless a proxy or firewall fronts it, and derive
`sessionKey` from the authenticated user rather than trusting the client.

The handler resolves `true` when it served the request and `false` (nothing
written) for any other route, like the `/chat` routes of `lousho dev`, so you
can mount it next to your own routes.
Expand Down
8 changes: 5 additions & 3 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,11 @@ curl -N http://127.0.0.1:3000/chat -H 'Content-Type: application/json' \
### Auth

Set `LOUSHO_API_TOKEN` (on the Worker target, as a secret: `npx wrangler secret
put LOUSHO_API_TOKEN`) and every route except `/health` requires
`Authorization: Bearer <token>`; anything else gets `401` with a JSON error
(the token is compared in constant time). Without it the server is open: that
put LOUSHO_API_TOKEN`) and the `/chat` routes and any channel without its
own `verify` require `Authorization: Bearer <token>`; anything else gets `401`
with a JSON error (the token is compared in constant time). `/health` is
open, and channels that authenticate themselves (a `verify` function, or a
platform signature check such as Slack's) are not gated by the token. Without it the server is open: that
is fine on `127.0.0.1`, but **treat the token as required for anything that is
not on localhost** (the server logs a warning when it listens on another
interface without one, and the `docker` image listens on all interfaces).
Expand Down
25 changes: 19 additions & 6 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4034,21 +4034,32 @@ import * as http from 'node:http';
import { createAgent, createMockProvider, httpChannel, mountChannels } from '@lousho/build-ai-agent';

const agent = createAgent({ instructions: 'You are helpful.', provider: createMockProvider() });
const channels = mountChannels(agent, [httpChannel()]);
const channels = mountChannels(agent, [
httpChannel({ verify: async (req) => req.headers.authorization === `Bearer ${process.env.API_TOKEN}` }),
]);

const server = http.createServer((req, res) => {
void channels(req, res).then((handled) => {
if (!handled) res.writeHead(404).end();
});
});
server.listen(3000);
server.listen(3000, '127.0.0.1');
```

```bash
curl -s localhost:3000/channels/http -H 'Content-Type: application/json' -d '{"sessionKey":"user-42","input":"Hi, I am Ali"}'
curl -s localhost:3000/channels/http -H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/json' -d '{"sessionKey":"user-42","input":"Hi, I am Ali"}'
# {"sessionId":"http_user-42-...","text":"...","finishReason":"stop"}
```

### Securing the http channel

Sessions are keyed by the `sessionKey` the caller sends, so an `httpChannel()`
without `verify` lets anyone who can reach the port read or continue another
user's conversation by guessing a key. It logs a warning once when created.
Always pass `verify` (a bearer-token check, as above, or your own session
check), bind to `127.0.0.1` unless a proxy or firewall fronts it, and derive
`sessionKey` from the authenticated user rather than trusting the client.

The handler resolves `true` when it served the request and `false` (nothing
written) for any other route, like the `/chat` routes of `lousho dev`, so you
can mount it next to your own routes.
Expand Down Expand Up @@ -7193,9 +7204,11 @@ curl -N http://127.0.0.1:3000/chat -H 'Content-Type: application/json' \
### Auth

Set `LOUSHO_API_TOKEN` (on the Worker target, as a secret: `npx wrangler secret
put LOUSHO_API_TOKEN`) and every route except `/health` requires
`Authorization: Bearer <token>`; anything else gets `401` with a JSON error
(the token is compared in constant time). Without it the server is open: that
put LOUSHO_API_TOKEN`) and the `/chat` routes and any channel without its
own `verify` require `Authorization: Bearer <token>`; anything else gets `401`
with a JSON error (the token is compared in constant time). `/health` is
open, and channels that authenticate themselves (a `verify` function, or a
platform signature check such as Slack's) are not gated by the token. Without it the server is open: that
is fine on `127.0.0.1`, but **treat the token as required for anything that is
not on localhost** (the server logs a warning when it listens on another
interface without one, and the `docker` image listens on all interfaces).
Expand Down
25 changes: 25 additions & 0 deletions src/channels/httpChannel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { httpChannel } from './httpChannel';

describe('httpChannel verify warning', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('warns exactly once per channel when no verify is given', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
httpChannel();
expect(warn).toHaveBeenCalledTimes(1);
const message = String(warn.mock.calls[0]?.[0]);
expect(message).toContain('[lousho]');
expect(message).toMatch(/unauthenticated/);
expect(message).toMatch(/sessionKey/);
expect(message).toMatch(/verify/);
});

it('does not warn when verify is given', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
httpChannel({ verify: async () => true });
expect(warn).not.toHaveBeenCalled();
});
});
9 changes: 9 additions & 0 deletions src/channels/httpChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface HttpChannelOptions {
* every request that is not a browser's cross-site one (`403`) and has a
* JSON `Content-Type` (else `415`), so a web page cannot post to it with a
* form; under `lousho dev` its `Host` is checked like the dev routes'.
* Omitting it logs a one-time warning: sessions are keyed by the
* caller-chosen `sessionKey`, so an unauthenticated channel exposes them.
*/
verify?: (req: ChannelRequest) => Promise<boolean>;
}
Expand All @@ -34,6 +36,13 @@ export interface HttpChannelOptions {
* ```
*/
export function httpChannel(options: HttpChannelOptions = {}): Channel {
if (!options.verify) {
console.warn(
'[lousho] httpChannel() has no `verify`: the channel is unauthenticated, and sessions are keyed by the caller-chosen `sessionKey`, ' +
"so anyone who can reach it can read or continue another user's conversation by guessing a key. " +
'Pass `verify: async (req) => req.headers.authorization === `Bearer ${token}`` (see docs/channels.md#securing-the-http-channel).'
);
}
return defineChannel({
name: options.name ?? 'http',
verify: options.verify,
Expand Down
Loading