Skip to content
Open
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ DEWEE_CLIENT_CREDENTIAL_HOSTS=
# Optional Cloudflare Turnstile bot check
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
# Optional: signed-in users through Dewee (read in your code and passed as the handler's `identity`
# option together with your authenticate(req) callback). From `dewee identity setup`. Server-side only.
DEWEE_IDENTITY_APP_ID=
DEWEE_IDENTITY_ISSUER=
DEWEE_IDENTITY_ASSERTION_SECRET=
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ jobs:
- run: pnpm typecheck
- run: pnpm test
- run: pnpm build
# dewee-webchat/server must bundle (webpack, esbuild) without the optional @dewee/identity SDK.
- run: pnpm test:bundlers

php:
runs-on: ubuntu-latest
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ npm i dewee-webchat

Full options, framework snippets and the security model: [packages/dewee-webchat/README.md](packages/dewee-webchat/README.md).

## Signed-in users

If your site knows who the visitor is, pass one server-side callback (`identity.authenticate`) to the
handler. It exchanges your user for a short-lived Dewee identity session, and the agent then chats as
that verified user, with private tools scoped to them. You no longer mint visitor tokens, push
per-visitor credentials or build your own account linking: a signed-in user links Telegram or Discord
from the widget with a one-time code. Anonymous visitors work exactly as before. Setup and the
fail-closed rules: [Signed-in users](packages/dewee-webchat/README.md#signed-in-users).

## Live examples

All of them run on Cloudflare and share the same scoped demo key.
Expand All @@ -51,6 +60,7 @@ All of them run on Cloudflare and share the same scoped demo key.
| [`cdn/`](cdn) | Worker that serves versioned bundles at `webchat-cdn.dewee.sh` |
| [`docs/http-contract.md`](docs/http-contract.md) | Wire format between the widget and any backend |
| [`examples/`](examples) | Runnable integrations |
| [`vendor/`](vendor) | Temporary: the packed `@dewee/identity` SDK used by the tests (see below) |

## Development

Expand All @@ -61,6 +71,12 @@ pnpm typecheck
pnpm build # packages/dewee-webchat/dist
```

`@dewee/identity` (the Dewee identity server SDK) is not published to npm yet. Until it is, the tests use a
tarball packed from the dewee repository (`packages/dewee-identity`, `pnpm --filter @dewee/identity pack`) and
committed as `vendor/dewee-identity-<version>.tgz`; `packages/dewee-webchat` depends on it as a dev dependency
through `file:`. This is temporary: once the SDK is published, the dev dependency switches to the npm version and
`vendor/` is deleted. For users the SDK is an optional peer dependency, needed only with the `identity` option.

Run the playground locally: copy `examples/cloudflare/.dev.vars.example` to `.dev.vars`, fill it in, then
`pnpm -C examples/cloudflare dev`.

Expand Down
113 changes: 113 additions & 0 deletions docs/http-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ implements these four routes works with the widget — the Node/Workers handler
`dewee-webchat/server` and the single-file PHP backend in `php/` both do.

Errors always use `{"error": {"message": string, "code": string}}` with a matching HTTP status.
Identity errors of [signed-in users](#signed-in-users) add `remediation`.

## `GET {base}/config`

Expand Down Expand Up @@ -114,6 +115,116 @@ messages are ordinary chat messages, so rate limits and `maxMessageLength` apply

**Errors** (JSON, before streaming): `400 invalid_actions`, `413 actions_too_large`, `413 context_too_large`.

## Signed-in users

Optional. A backend that signs users in through Dewee (the Node/Workers handler with the `identity`
option; not the PHP backend yet) changes the routes below. Without it nothing here applies and every
request and response is exactly as described above.

**`GET {base}/config`** adds `"identity": true`, sets `allowAgentSwitch` to `false` and `defaultAgent`
to the agent sessions are bound to. The widget then asks `/session` on every page load and keeps a
signed-in session in memory only.

**`POST {base}/session`** first asks the host who the visitor is (server side, from its own login):

- Signed in: the backend exchanges the user for a Dewee identity session
(`POST {gateway}/v1/identity/sessions/exchange` with a signed partner assertion), or reuses the one it
holds for that user until it nears expiry, and answers
`{ "visitorId", "token", "expiresAt", "verified": true, "expiresIn" }`. `expiresIn` is the
milliseconds the session stays valid, counted from receipt; the widget refreshes
`min(60 s, expiresIn/2)` before the end. The visitor token carries two more claims, `ids` and `ida`
(the bound agent): `base64url(JSON{"v", "exp", "ids", "ida"})`. `ids` is opaque:
`base64url(iv ‖ AES-256-GCM(key, JSON{"t": <identity session>, "h": <subject digest>}))`, the key
derived from the backend secret with HKDF-SHA-256 (info `dewee-webchat:identity-session:aes-256-gcm:v1`)
and the associated data binding it to `v` and `ida`. `h` is an HMAC of the host's subject, never the
subject itself. `exp` is the earlier of the identity session expiry (measured from receipt on the
backend's clock) and the visitor session lifetime. `visitorId` is derived from the Dewee principal, so
the same user keeps the same conversations everywhere. If the exchange fails, no token is issued.
- Not signed in: an anonymous visitor token, unless the backend requires sign-in, in which case the
answer is `401 IDENTITY_REQUIRED`. A still-valid anonymous `token` in the body (more than a day left)
is returned as is.
- The host's login check failed (`authenticate` threw): `503 SOURCE_UNAVAILABLE`, never an anonymous
token.

The gateway token, the partner assertion secret, the private key, the raw identity session and the
host's subject never appear in any response. The identity session is a bearer credential for the user
until it expires or is revoked: chat and history with the bound agent, starting web-first links, and
listing and removing the user's own linked chat accounts (`/v1/identity/me/bindings`).

A widget on another origin sends no cookies of the backend's site, so a cookie-based login check only
works same-origin.

**`POST {base}/chat`, `GET {base}/history`** with a token that carries `ids`: the backend connects with
`{"token": …, "identity_session": <identity session>, "tenant_id"?: …}` and no `user_id`; the gateway
derives the user. The backend goes on only when the connect answer's `user_id` is `tuc:<principal>`;
anything else (a gateway that ignored the session) is `503 SOURCE_UNAVAILABLE` and nothing runs. The
request is bound to the `ida` agent, which must still be allowed (another `agent`, or an `ida` no longer
in the allowlist, is `403 agent_not_allowed`). The connection is checked before the stream starts, so a
refused session is a JSON error, not an SSE event. A token with `ids` is refused (401) by a backend
that does not sign users in.

By default the request is bound to the token's user whatever cookies it carries. A backend with
**per-request rebinding** (`rebindEachRequest`, recommended with cookie logins) checks the host's login
again on `/chat`, `/history` and `/link`: a different user or none is `401 IDENTITY_REQUIRED` (the widget
asks `/session` again), a failed check `503 SOURCE_UNAVAILABLE`. A widget token replayed under another
user's cookie is then useless.

**Rate limits** (the Node/Workers defaults): host login checks 120 per IP per minute (before the check
runs, on `/session` and on rebinding), signed-in `/session` 30 per user per 10 minutes, `/link` 5 per
user per 10 minutes, new anonymous sessions 10 per IP per 10 minutes, messages 20 per minute. Every
limit answers `429 rate_limited`. The host's login check runs on every `/session` (every page load) and,
with rebinding, on every `/chat`, `/history` and `/link`, so it should be cheap or cached. The IP
comes from `cf-connecting-ip`, `x-real-ip` or `x-forwarded-for`, which only a proxy in front can vouch
for (unchanged from before signed-in users).

**Identity errors.** The gateway's typed errors are forwarded with their remediation:

```json
{ "error": { "message": "Your session expired.", "code": "LINK_EXPIRED",
"remediation": { "action": "reauthenticate", "audience": "end_user" } } }
```

| `code` | Status | |
|---|---|---|
| `IDENTITY_REQUIRED` | 401 | No valid identity (not signed in, unknown session) |
| `LINK_EXPIRED` | 401 | The identity session expired; the widget asks `/session` again |
| `LINK_REVOKED` | 401 | Access was revoked or the user suspended; the widget asks `/session` again |
| `IDENTITY_LINK_REQUIRED` | 403 | The chat account is not linked yet |
| `PERMISSION_DENIED` | 403 | Verified but not allowed (for example another agent) |
| `IDENTITY_CONFLICT` | 409 | The account is linked to another user |
| `SOURCE_UNAVAILABLE` | 503 | Identity could not be checked (host login check failed, gateway did not apply the session); nothing ran |

An identity error raised during a run arrives as the SSE event
`{"type": "error", "message", "code", "remediation"}`. The widget shows its own localized text for
each code and, after `LINK_EXPIRED` or `LINK_REVOKED`, requests `/session` again.

On `/session` (the exchange), only errors whose remediation is for the `end_user` are forwarded typed.
One addressed to the `operator` or `integrator` (the application or agent set up wrong), or an exchange
route switched off on the gateway, is `500 identity_misconfigured` without detail; the reason goes to
the backend's log. Other exchange failures are `502 gateway_error` or `429 rate_limited`, without detail.

### `POST {base}/link`

Web-first linking of a chat account for a signed-in user. Headers: `Authorization: Bearer <visitor
token with ids>`. Body: `{ "idempotencyKey"?: string }` (1 to 255 printable ASCII characters). The
backend calls `POST {gateway}/v1/identity/links` with the identity session and answers:

```json
{ "proofCode": "482913", "expiresAt": 1791621449203,
"deepLinks": [{ "channelType": "telegram", "channelInstanceId": "…", "name": "acme-bot", "displayName": "Acme Bot" }] }
```

The user sends `proofCode` in a direct message from the chat account to link; Dewee checks both sides
and links on its own. Nobody types an account id. A visitor without an identity session gets
`401 IDENTITY_REQUIRED`; a backend that does not sign users in answers `404`. Other answers:

| `code` | Status | |
|---|---|---|
| `link_in_progress` | 409 | A link is already in progress; use its code or wait until it expires |
| `idempotency_key_reused` | 409 | The `idempotencyKey` was already used for a different request |
| `linking_unavailable` | 503 | The gateway has linking switched off |
| `LINK_EXPIRED`, `LINK_REVOKED` | 401 | The widget renews the session once and retries |

## `GET {base}/history?agent=…&conversationId=…`

Headers: `Authorization: Bearer <visitor token>`. Returns the visitor's own conversation:
Expand All @@ -138,6 +249,8 @@ refused unless explicitly allowlisted.
The backend opens a WebSocket to `{gateway}/ws` (protocol v3):

1. `{"type":"req","id":"1","method":"connect","params":{"token":…,"user_id":"webchat-<visitorId>","tenant_id"?:…}}`
(for a signed-in user: `"identity_session": …` instead of `user_id`; a refused session answers
`{"ok":false,"error":{"code":"LINK_EXPIRED",…,"details":{"remediation":{…}}}}`)
2. `{"type":"req","id":"2","method":"chat.send","params":{"agentId":…,"sessionKey":…,"message":…,"stream":true}}`
3. Gateway pushes `{"type":"event","event":"agent","payload":{"type":…,"sessionKey":…,"payload":{…}}}`
with `type` ∈ `run.started`, `thinking {content}`, `chunk {content}`, `tool.call {id,name,arguments}`,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"build": "pnpm --filter dewee-webchat build",
"test": "pnpm --filter dewee-webchat test",
"test:bundlers": "pnpm --filter dewee-webchat test:bundlers",
"typecheck": "pnpm --filter dewee-webchat typecheck"
},
"pnpm": {
Expand Down
Loading
Loading