diff --git a/.env.example b/.env.example index 1d9039a..86dc102 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f31a931..12872fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 27857dd..dba2b69 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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-.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`. diff --git a/docs/http-contract.md b/docs/http-contract.md index e528b6f..14873f6 100644 --- a/docs/http-contract.md +++ b/docs/http-contract.md @@ -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` @@ -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": , "h": }))`, 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": , "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:`; +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 `. 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 `. Returns the visitor's own conversation: @@ -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-","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}`, diff --git a/package.json b/package.json index 31aa1dd..9d7766b 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/dewee-webchat/README.md b/packages/dewee-webchat/README.md index b3b256e..b1be327 100644 --- a/packages/dewee-webchat/README.md +++ b/packages/dewee-webchat/README.md @@ -129,8 +129,9 @@ Attributes use kebab-case (`show-reasoning="false"`), `data-*` on the script tag | `actionTimeoutMs` | `30000` | Per-handler timeout | Methods: `open()`, `close()`, `toggle()`, `send(text)`, `stop()`, `newConversation()`, `configure(partial)`, -`registerActions(actions)`, `unregisterAction(name)`. -Events: `dewee:ready`, `dewee:open`, `dewee:close`, `dewee:send`, `dewee:reply`, `dewee:error`, `dewee:action`. +`registerActions(actions)`, `unregisterAction(name)`, and for [signed-in users](#signed-in-users) +`renewSession()` and `startLink()`. +Events: `dewee:ready`, `dewee:open`, `dewee:close`, `dewee:send`, `dewee:reply`, `dewee:error`, `dewee:action`, `dewee:link`. CSS variables: `--dw-accent`, `--dw-font`, `--dw-radius`, `--dw-width`, `--dw-height`, `--dw-z`. ## Agent page actions @@ -195,13 +196,132 @@ or inserts it as HTML, caps calls per answer and automatic turns per message, an `mutate`/`destructive` handler without approval. Page context and results are labelled as data for the agent, but prompt injection through page content is still possible — do not put secrets in either. +## Signed-in users + +When your site knows who the visitor is, the agent can know it too, with private tools that act for +that user only. You pass one server-side callback. You do not mint tokens, store per-visitor +credentials or put anything secret in the page: the handler exchanges your user for a Dewee identity +session, and the gateway derives the user from it. + +```ts +import { createDeweeHandler, optionsFromEnv } from "dewee-webchat/server"; +import { deweeIdentitySdk } from "dewee-webchat/server/identity"; + +const handler = createDeweeHandler({ + ...optionsFromEnv(process.env), + identity: { + sdk: deweeIdentitySdk, + // Your own login. Return null for a visitor who is not signed in. + authenticate: async (req) => { + const user = await getUserFromSessionCookie(req.headers.get("cookie")); + return user ? { subject: user.id } : null; // your stable id, never an email address + }, + rebindEachRequest: true, // recommended with a cookie login, see below + appId: process.env.DEWEE_IDENTITY_APP_ID!, + issuer: process.env.DEWEE_IDENTITY_ISSUER!, + assertionSecret: process.env.DEWEE_IDENTITY_ASSERTION_SECRET!, // or privateKeyJwk for a jwks application + }, +}); +``` + +Setup: create the application once with `dewee identity setup --name --issuer ` (it +prints the application id and the assertion secret), and install the server SDK next to this package: +`npm install @dewee/identity`. It is an optional peer dependency used only by the +`dewee-webchat/server/identity` entry; `dewee-webchat/server` itself never imports it, so a site that +serves only anonymous visitors bundles fine (webpack, esbuild…) without it. +`@dewee/identity` is not on npm yet: until it is, build its tarball from the +[dewee repository](https://github.com/nextlevelbuilder/dewee/blob/dev/packages/dewee-identity/README.md) +(`pnpm --filter @dewee/identity pack`) and install that file. + +| `identity` option | | +|---|---| +| `sdk` | Required. `deweeIdentitySdk` from `dewee-webchat/server/identity`. | +| `authenticate(req)` | Required. Resolves `{ subject }` for a signed-in user, `null` otherwise. Throwing means "could not check". | +| `appId`, `issuer` | Required. The Dewee application id and its issuer, exactly. | +| `assertionSecret` \| `privateKeyJwk` (+ `algorithm`) | Exactly one: `hmac` or `jwks` application. Server only. | +| `audience` | Optional check; must be `dewee:`. | +| `agent` | The agent sessions are bound to. Default: `defaultAgent`, else the first of `agents`. | +| `required` | Default `false`. When `true`, visitors who are not signed in get no session at all. | +| `rebindEachRequest` | Default `false`. Check the signed-in user again on every `/chat`, `/history` and `/link`. | +| `sessionTtlSeconds` | 60 to 43200. Default: the gateway default (3600). | +| `allowInsecureHttp` | Allow plain http to a gateway that is not loopback (private networks only). | + +`AuthenticatedUser` has no scopes: the session exchange does not carry any. When a user's permissions +change, report it from your server with `lifecycle({ event: "scopes_changed", … })` from +`@dewee/identity`. + +How it behaves: + +- `POST /session` calls `authenticate`. For a user it returns a visitor token that carries a Dewee + identity session, encrypted (AES-256-GCM, key derived from `secret`) so the browser only holds an + opaque value. The gateway token, the assertion secret, the private key, the raw identity session and + your subject never reach the browser. +- One identity session per user is kept in memory and reused until it nears expiry, so a page load + does not cost an exchange; a session the gateway reports expired or revoked is dropped. +- `/chat` and `/history` connect to the gateway with that `identity_session` and no `user_id`, and only + go on when the gateway confirms it derived a Dewee user (`tuc:…`); a gateway that ignored the session + (identity switched off) gets `503 SOURCE_UNAVAILABLE` and nothing runs. The same user gets the same + conversations on every device. The session's agent must still be in `agents` on every request. +- By default the visitor token is the credential: a token for user A keeps chatting as A even if a + later request carries B's cookie, and an altered token is refused (401). With + `rebindEachRequest: true`, `/chat`, `/history` and `/link` call `authenticate(req)` again and compare + it with a keyed digest of the subject sealed in the token: another user or no user is + `401 IDENTITY_REQUIRED` (the widget then asks `/session` again), and a throwing `authenticate` is + `503 SOURCE_UNAVAILABLE`. Turn it on when your login is a cookie: a widget token replayed under + another user's cookie is then useless. +- Fail closed. If `authenticate` returns `null`, the visitor chats anonymously, exactly as without + `identity`, unless `required` is set, in which case no session is issued. If it throws, the answer + is `503 SOURCE_UNAVAILABLE` and no session is issued. If the exchange for a signed-in user fails, no + session is issued: a user is never silently downgraded. An exchange refusal meant for the operator + (an application or agent set up wrong) is `500 identity_misconfigured`, with the reason in your logs. +- What the identity session allows, until it expires or is revoked: chatting with the bound agent and + reading that chat's history as the user, starting web-first links, and listing and removing the + user's own linked chat accounts (`/v1/identity/me/bindings`). That is why it never reaches the + browser in the clear. +- `authenticate` sees the request the widget sends. A widget on another origin (`allowedOrigins`) + sends no cookies of your site, so a cookie-based `authenticate` only works same-origin. +- `DEWEE_GATEWAY_TOKEN` must be a tenant API key without an owner: the gateway refuses an identity + session next to a key bound to a user (`IDENTITY_CONFLICT`). +- `identity` cannot be combined with `allowClientCredentials` (the handler refuses to start). Sessions + are bound to one agent, so `/config` turns the agent picker off. +- Gateway identity errors (`IDENTITY_REQUIRED`, `IDENTITY_LINK_REQUIRED`, `LINK_EXPIRED`, `LINK_REVOKED`, + `PERMISSION_DENIED`, `SOURCE_UNAVAILABLE`, `IDENTITY_CONFLICT`) reach the widget as + `{ error: { code, remediation } }`. The widget shows its own localized message and, on `LINK_EXPIRED` + or `LINK_REVOKED`, asks `/session` again. +- The widget keeps a signed-in session in memory only, so each page load asks your backend who the + visitor is. It refreshes the session `min(60 s, lifetime/2)` before it ends, counting the lifetime + (`expiresIn`) from when it received it. Call `chat.renewSession()` after a sign-in or sign-out + without a page load. +- Rate limits (default, in memory): 120 `authenticate` calls per IP per minute (checked before + `authenticate` runs), 30 signed-in `/session` calls per user per 10 minutes, 5 link starts per user per + 10 minutes. A visitor who is not signed in and still has a valid anonymous token gets it back as is; + only minting a new one counts against the 10 per IP per 10 minutes. A custom `rateLimit` receives the + buckets `preauth`, `verified` and `link` besides `session` and `chat`. +- Keep `authenticate` cheap or cached (a signed-cookie check, not a database round trip per call): it + runs on every `/session`, so on every page load, and with `rebindEachRequest` on every `/chat`, + `/history` and `/link`, with only the generous per-IP cap in front of it. + +**Linking a chat app.** A signed-in user can link their Telegram, Discord or other chat account +without typing any id: call `chat.startLink()`. The widget asks `POST {endpoint}/link`, which starts +a Dewee web-first link, and shows the proof code with "Continue on Telegram" for each chat account +the code can be sent to. The user sends the code in a direct message; Dewee checks both sides and +links on its own. `dewee:link` fires with `{ expiresAt, deepLinks }`. A link that is already in +progress answers `409 link_in_progress`, a reused idempotency key `409 idempotency_key_reused`, and a +gateway with linking switched off `503 linking_unavailable`; the widget shows its own text for each. + +The PHP backend does not support signed-in users yet (it has no JWS signing); use the Node/Workers handler. + ## Security model - The gateway token never leaves your server. Browsers get an HMAC-signed visitor id. - Each visitor gets its own gateway user (`webchat-`) and session keys, so history is private to them. + A [signed-in user](#signed-in-users) is the user the gateway derives from their Dewee identity session. - Only allowlisted agents are reachable; origin allowlist, rate limits (20 messages/min per visitor, 10 new sessions/10 min per IP by default — pass `rateLimit` for a shared store), 4000-char messages, optional Turnstile. +- The client IP for per-IP limits comes from `cf-connecting-ip`, `x-real-ip` or the first + `x-forwarded-for` entry. These headers are only trustworthy behind a proxy that sets them (as + before signed-in users existed); elsewhere a client can choose its own IP. - "Bring your own gateway" (`allowClientCredentials`) is off by default; when on, internal hosts are refused. The wire format is documented in [docs/http-contract.md](https://github.com/nextlevelbuilder/dewee-webchat/blob/main/docs/http-contract.md). diff --git a/packages/dewee-webchat/package.json b/packages/dewee-webchat/package.json index f2c0cd3..06b477c 100644 --- a/packages/dewee-webchat/package.json +++ b/packages/dewee-webchat/package.json @@ -45,6 +45,10 @@ "types": "./dist/server.d.ts", "import": "./dist/server.js" }, + "./server/identity": { + "types": "./dist/server-identity.d.ts", + "import": "./dist/server-identity.js" + }, "./react": { "types": "./dist/react.d.ts", "import": "./dist/react.js" @@ -67,13 +71,18 @@ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsup", "dev": "tsup --watch", "test": "vitest run", + "test:bundlers": "node scripts/bundler-smoke.mjs", "typecheck": "tsc --noEmit" }, "peerDependencies": { + "@dewee/identity": ">=0.1.0-beta.0 <1", "react": ">=18", "vue": ">=3.3" }, "peerDependenciesMeta": { + "@dewee/identity": { + "optional": true + }, "react": { "optional": true }, @@ -82,15 +91,18 @@ } }, "devDependencies": { + "@dewee/identity": "file:../../vendor/dewee-identity-0.1.0-beta.0.tgz", "@types/node": "^22.15.0", "@types/react": "^19.1.0", "@types/ws": "^8.18.2", + "esbuild": "^0.28.2", "happy-dom": "^18.0.1", "react": "^19.1.0", "tsup": "^8.5.0", "typescript": "^5.9.2", "vitest": "^3.2.4", "vue": "^3.5.18", + "webpack": "^5.112.0", "ws": "^8.22.0" }, "engines": { diff --git a/packages/dewee-webchat/scripts/bundler-smoke.mjs b/packages/dewee-webchat/scripts/bundler-smoke.mjs new file mode 100644 index 0000000..15ac056 --- /dev/null +++ b/packages/dewee-webchat/scripts/bundler-smoke.mjs @@ -0,0 +1,107 @@ +// Bundler smoke test of the built package (run `pnpm build` first): a host that only serves anonymous +// visitors must be able to bundle `dewee-webchat/server` with webpack and esbuild without +// `@dewee/identity` installed. The SDK is made unresolvable for that bundle, so any reference to it, +// static or dynamic, fails the build. As a control, `dewee-webchat/server/identity` must bundle when +// the SDK is present. +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import * as esbuild from "esbuild"; +import webpack from "webpack"; + +const pkgDir = fileURLToPath(new URL("..", import.meta.url)); +const dist = (f) => join(pkgDir, "dist", f).replace(/\\/g, "/"); +const SDK = "@dewee/identity"; +const work = mkdtempSync(join(tmpdir(), "dewee-webchat-bundlers-")); +const failures = []; + +const anonymousEntry = join(work, "anonymous.mjs"); +writeFileSync( + anonymousEntry, + `import { createDeweeHandler } from ${JSON.stringify(dist("server.js"))};\n` + + `const h = createDeweeHandler({ gatewayUrl: "https://gw.example.com", token: "t", secret: "s".repeat(32), agents: ["support"] });\n` + + `const res = await h(new Request("https://site.test/api/dewee/config", { method: "OPTIONS" }));\n` + + `if (res.status !== 204) throw new Error("unexpected status " + res.status);\n` + + `console.log("anonymous handler ok");\n`, +); +const identityEntry = join(work, "identity.mjs"); +writeFileSync(identityEntry, `import { deweeIdentitySdk } from ${JSON.stringify(dist("server-identity.js"))};\nconsole.log(typeof deweeIdentitySdk.exchangeSession);\n`); + +async function step(name, fn) { + try { + await fn(); + console.log(`ok ${name}`); + } catch (e) { + failures.push(name); + console.error(`FAIL ${name}\n${e?.message ?? e}`); + } +} + +/** esbuild plugin: the SDK is not installed. */ +const sdkAbsent = { + name: "sdk-absent", + setup(build) { + build.onResolve({ filter: /^@dewee\/identity(\/.*)?$/ }, () => ({ errors: [{ text: `${SDK} is not installed` }] })); + }, +}; + +await step("dist/server.js does not import the SDK", () => { + const importOf = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)["']@dewee\/identity["']/; + if (importOf.test(readFileSync(dist("server.js"), "utf8"))) throw new Error(`dist/server.js imports ${SDK}`); +}); + +await step("esbuild bundles the anonymous server without the SDK, and the bundle runs", async () => { + const outfile = join(work, "esbuild-anonymous.mjs"); + await esbuild.build({ entryPoints: [anonymousEntry], bundle: true, platform: "node", format: "esm", outfile, plugins: [sdkAbsent], logLevel: "silent" }); + await import(pathToFileURL(outfile).href); +}); + +function runWebpack(entry, filename, alias) { + return new Promise((resolve, reject) => { + webpack( + { + mode: "production", + target: "node", + entry, + experiments: { outputModule: true }, + output: { path: work, filename, module: true, chunkFormat: "module" }, + resolve: { alias }, + optimization: { minimize: false }, + }, + (err, stats) => { + if (err) return reject(err); + if (stats.hasErrors()) return reject(new Error(stats.toString({ all: false, errors: true }))); + resolve(); + }, + ); + }); +} + +await step("webpack bundles the anonymous server without the SDK, and the bundle runs", async () => { + // Aliasing the SDK to a missing file makes any reference to it a "Module not found" error. + await runWebpack(anonymousEntry, "webpack-anonymous.mjs", { [SDK]: join(work, "not-installed", "missing.js") }); + await import(pathToFileURL(join(work, "webpack-anonymous.mjs")).href); +}); + +await step("control: esbuild bundles dewee-webchat/server/identity with the SDK installed", async () => { + await esbuild.build({ entryPoints: [identityEntry], bundle: true, platform: "node", format: "esm", outfile: join(work, "esbuild-identity.mjs"), nodePaths: [join(pkgDir, "node_modules")], logLevel: "silent" }); +}); + +await step("control: esbuild refuses dewee-webchat/server/identity without the SDK", async () => { + let refused = false; + try { + await esbuild.build({ entryPoints: [identityEntry], bundle: true, platform: "node", format: "esm", write: false, plugins: [sdkAbsent], logLevel: "silent" }); + } catch (e) { + refused = String(e?.message ?? e).includes(`${SDK} is not installed`); + if (!refused) throw e; + } + if (!refused) throw new Error("the identity entry bundled without the SDK: the check would not catch a reference"); +}); + +rmSync(work, { recursive: true, force: true }); +if (failures.length) { + console.error(`${failures.length} bundler check(s) failed`); + process.exit(1); +} +console.log("all bundler checks passed"); diff --git a/packages/dewee-webchat/src/index.ts b/packages/dewee-webchat/src/index.ts index 8a944bc..bdaefd7 100644 --- a/packages/dewee-webchat/src/index.ts +++ b/packages/dewee-webchat/src/index.ts @@ -3,7 +3,7 @@ import { DeweeChatElement, type DeweeChatConfig } from "./widget/element"; export { DeweeChatElement, type DeweeChatConfig }; export { ApiClient, ApiError, type ApiClientOptions } from "./widget/api-client"; export { renderMarkdown } from "./widget/markdown"; -export type { ActionManifestEntry, ActionSensitivity, AgentInfo, ChatRequestBody, ChatStreamEvent, HistoryMessage, PublicConfig, SessionResponse, ToolCallView } from "./protocol"; +export type { ActionManifestEntry, ActionSensitivity, AgentInfo, ChatRequestBody, ChatStreamEvent, HistoryMessage, IdentityErrorCode, LinkResponse, LinkTarget, PublicConfig, Remediation, SessionResponse, ToolCallView } from "./protocol"; export type { ActionCallStatus, ActionConfirmHook, PageAction, PageActionContext, PageActionInfo } from "./actions/action-runner"; export type { ActionResult, ActionResultStatus } from "./actions/directives"; export type { JsonSchema } from "./actions/json-schema-subset"; diff --git a/packages/dewee-webchat/src/protocol.ts b/packages/dewee-webchat/src/protocol.ts index 1d5c20b..eb9f4a1 100644 --- a/packages/dewee-webchat/src/protocol.ts +++ b/packages/dewee-webchat/src/protocol.ts @@ -9,7 +9,35 @@ export type ChatStreamEvent = | { type: "tool_call"; id: string; name: string; arguments?: unknown } | { type: "tool_result"; id: string; name: string; isError: boolean; result?: string } | { type: "done"; content: string; usage?: Record } - | { type: "error"; message: string; code?: string }; + | { type: "error"; message: string; code?: string; remediation?: Remediation }; + +/** The seven typed identity errors of the Dewee gateway, forwarded as `error.code` for signed-in users. */ +export const IDENTITY_ERROR_CODES = [ + "IDENTITY_REQUIRED", + "IDENTITY_LINK_REQUIRED", + "IDENTITY_CONFLICT", + "LINK_EXPIRED", + "LINK_REVOKED", + "PERMISSION_DENIED", + "SOURCE_UNAVAILABLE", +] as const; + +export type IdentityErrorCode = (typeof IDENTITY_ERROR_CODES)[number]; + +export function isIdentityErrorCode(code: unknown): code is IdentityErrorCode { + return typeof code === "string" && (IDENTITY_ERROR_CODES as readonly string[]).includes(code); +} + +/** What to do about an identity error: `action` (e.g. `reauthenticate`, `retry_later`) and who acts (`end_user`, `operator`, `integrator`). */ +export interface Remediation { + action: string; + audience: string; +} + +/** Error body of every JSON route. `remediation` is present for identity errors only. */ +export interface ErrorBody { + error: { message: string; code: string; remediation?: Remediation }; +} export interface AgentInfo { id: string; @@ -26,6 +54,8 @@ export interface PublicConfig { turnstileSiteKey?: string; maxMessageLength: number; version: string; + /** Present (true) when the backend signs users in through Dewee: the widget then asks `/session` on every page load. */ + identity?: boolean; } /** Response of `POST {basePath}/session`. */ @@ -33,6 +63,41 @@ export interface SessionResponse { visitorId: string; token: string; expiresAt: number; + /** Present (true) when the session carries a verified Dewee identity. Such sessions are short-lived and never persisted. */ + verified?: boolean; + /** + * Verified sessions only: milliseconds the session stays valid, counted from when the response is + * received. The widget refreshes from this rather than `expiresAt`, so a skewed browser clock does not + * matter. + */ + expiresIn?: number; +} + +/** Error codes of `POST {basePath}/link` besides the identity codes. */ +export const LINK_ERROR_CODES = ["idempotency_key_reused", "link_in_progress", "linking_unavailable"] as const; + +export type LinkErrorCode = (typeof LINK_ERROR_CODES)[number]; + +export function isLinkErrorCode(code: unknown): code is LinkErrorCode { + return typeof code === "string" && (LINK_ERROR_CODES as readonly string[]).includes(code); +} + +/** A chat account the proof code of a web-first link can be sent to. */ +export interface LinkTarget { + /** e.g. `telegram`, `discord`, `zalo`. */ + channelType: string; + channelInstanceId: string; + name: string; + displayName?: string; +} + +/** Response of `POST {basePath}/link`. */ +export interface LinkResponse { + /** Show it exactly as given; the user sends it in a direct chat from the account to link. */ + proofCode: string; + /** Epoch ms. */ + expiresAt: number; + deepLinks: LinkTarget[]; } export interface ToolCallView { diff --git a/packages/dewee-webchat/src/server/gateway-client.ts b/packages/dewee-webchat/src/server/gateway-client.ts index 6e22c64..11d4c09 100644 --- a/packages/dewee-webchat/src/server/gateway-client.ts +++ b/packages/dewee-webchat/src/server/gateway-client.ts @@ -1,7 +1,7 @@ // Minimal Dewee gateway WebSocket RPC client (protocol v3). // Runs on Cloudflare Workers (fetch upgrade) and on any runtime with a global WebSocket // (Node >= 22, Bun, Deno, Workers with the WebSocket constructor). -import type { AgentInfo, ChatStreamEvent, HistoryMessage, ToolCallView } from "../protocol"; +import type { AgentInfo, ChatStreamEvent, HistoryMessage, Remediation, ToolCallView } from "../protocol"; import { stripPageBlock } from "../actions/page-block"; export interface GatewayCredentials { @@ -13,12 +13,18 @@ export interface GatewayCredentials { tenantId?: string; } +/** + * Who a connection acts as: a gateway user id chosen by this server (anonymous visitors), or a Dewee + * identity session, in which case the gateway derives the user and no `user_id` is sent. + */ +export type GatewayActor = string | { identitySession: string }; + interface Frame { type: "res" | "event"; id?: string; ok?: boolean; payload?: any; - error?: { code?: string; message?: string }; + error?: { code?: string; message?: string; details?: { remediation?: unknown } }; event?: string; } @@ -29,11 +35,22 @@ type SocketLike = { }; export class GatewayError extends Error { - constructor(message: string, public code?: string) { + constructor(message: string, public code?: string, public remediation?: Remediation) { super(message); } } +function parseRemediation(raw: unknown): Remediation | undefined { + if (raw === null || typeof raw !== "object") return undefined; + const { action, audience } = raw as Record; + return typeof action === "string" && typeof audience === "string" ? { action, audience } : undefined; +} + +/** A user the gateway derived from an identity session: `tuc:`. */ +function isIdentityUser(userId: unknown): boolean { + return typeof userId === "string" && userId.startsWith("tuc:") && userId.length > 4; +} + function wsUrl(base: string): string { const u = new URL(base); u.protocol = u.protocol === "http:" ? "ws:" : "wss:"; @@ -75,12 +92,18 @@ export class GatewayConnection { ws.addEventListener("error", () => this.fail(new GatewayError("Gateway connection error", "socket_error"))); } - static async connect(creds: GatewayCredentials, userId: string, timeoutMs = 15000): Promise { + static async connect(creds: GatewayCredentials, actor: GatewayActor, timeoutMs = 15000): Promise { const conn = new GatewayConnection(await openSocket(creds.url, timeoutMs)); - const params: Record = { token: creds.token, user_id: userId }; + const params: Record = + typeof actor === "string" ? { token: creds.token, user_id: actor } : { token: creds.token, identity_session: actor.identitySession }; if (creds.tenantId) params.tenant_id = creds.tenantId; try { - await conn.call("connect", params, timeoutMs); + const res = await conn.call<{ user_id?: unknown } | undefined>("connect", params, timeoutMs); + // Fail closed: a gateway that did not apply the identity session (identity switched off, or a + // replica that failed to start it) would otherwise run the turn as the operator token's own user. + if (typeof actor !== "string" && !isIdentityUser(res?.user_id)) { + throw new GatewayError("Gateway did not apply the identity session", "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "operator" }); + } } catch (e) { conn.close(); throw e; @@ -121,7 +144,7 @@ export class GatewayConnection { const p = this.pending.get(frame.id)!; this.pending.delete(frame.id); if (frame.ok) p.resolve(frame.payload); - else p.reject(new GatewayError(frame.error?.message || "Gateway request failed", frame.error?.code)); + else p.reject(new GatewayError(frame.error?.message || "Gateway request failed", frame.error?.code, parseRemediation(frame.error?.details?.remediation))); } else if (frame.type === "event" && frame.event) { for (const cb of this.listeners) cb(frame.event, frame.payload); } @@ -140,7 +163,12 @@ export function buildSessionKey(agent: string, conversationId: string): string { export interface StreamChatOptions { creds: GatewayCredentials; - userId: string; + /** Gateway user id (anonymous visitors). Ignored when `identitySession` or `connection` is set. */ + userId?: string; + /** Dewee identity session: the gateway derives the user; no `user_id` is sent. */ + identitySession?: string; + /** An already connected connection to use (and close) instead of opening one. */ + connection?: GatewayConnection; agent: string; sessionKey: string; message: string; @@ -148,9 +176,15 @@ export interface StreamChatOptions { timeoutMs?: number; } +function actorOf(opts: { userId?: string; identitySession?: string }): GatewayActor { + if (opts.identitySession) return { identitySession: opts.identitySession }; + if (opts.userId) return opts.userId; + throw new GatewayError("A user id or an identity session is required", "invalid_actor"); +} + /** Runs one chat turn and yields normalized stream events until the run ends. */ export async function* streamChat(opts: StreamChatOptions): AsyncGenerator { - const conn = await GatewayConnection.connect(opts.creds, opts.userId); + const conn = opts.connection ?? (await GatewayConnection.connect(opts.creds, actorOf(opts))); const queue: ChatStreamEvent[] = []; let wake: (() => void) | null = null; let finished = false; @@ -182,7 +216,10 @@ export async function* streamChat(opts: StreamChatOptions): AsyncGenerator { push({ type: "done", content: res?.content ?? "", usage: res?.usage }); end(); }) - .catch((err: GatewayError) => { if (!finished) push({ type: "error", message: err.message, code: err.code }); end(); }); + .catch((err: GatewayError) => { + if (!finished) push(err.remediation ? { type: "error", message: err.message, code: err.code, remediation: err.remediation } : { type: "error", message: err.message, code: err.code }); + end(); + }); try { while (true) { @@ -205,8 +242,8 @@ function clip(v: unknown, max = 4000): string | undefined { } /** Fetches a session transcript and folds tool calls/results into assistant turns. */ -export async function fetchHistory(creds: GatewayCredentials, userId: string, agent: string, sessionKey: string): Promise { - const conn = await GatewayConnection.connect(creds, userId); +export async function fetchHistory(creds: GatewayCredentials, actor: GatewayActor, agent: string, sessionKey: string): Promise { + const conn = await GatewayConnection.connect(creds, actor); try { const res = await conn.call<{ messages?: any[] }>("chat.history", { agentId: agent, sessionKey }, 15000); return foldHistory(res?.messages ?? []); diff --git a/packages/dewee-webchat/src/server/handler.ts b/packages/dewee-webchat/src/server/handler.ts index 26f1a54..1d71c69 100644 --- a/packages/dewee-webchat/src/server/handler.ts +++ b/packages/dewee-webchat/src/server/handler.ts @@ -1,25 +1,139 @@ // Framework-agnostic backend: (Request) => Promise. // Routes are matched by the last path segment, so mount it under any prefix -// (e.g. /api/dewee/[...path]): GET config | POST session | POST chat | GET history | OPTIONS. +// (e.g. /api/dewee/[...path]): GET config | POST session | POST chat | GET history | POST link (identity) | OPTIONS. import { HEADER_GATEWAY_TOKEN, HEADER_GATEWAY_URL, VERSION, + isIdentityErrorCode, type AgentInfo, type ChatStreamEvent, + type IdentityErrorCode, + type LinkResponse, type PublicConfig, + type Remediation, + type SessionResponse, } from "../protocol"; +// This module never imports `@dewee/identity`, not even lazily, so bundlers of anonymous-only hosts never +// look for it. Identity mode gets the SDK through `identity.sdk` (from `dewee-webchat/server/identity`). import { ActionInputError, buildPageBlock, hashManifest, normalizeManifest, serializePageContext } from "../actions/page-block"; -import { buildSessionKey, fetchHistory, listAgents, streamChat, type GatewayCredentials, type GatewayError } from "./gateway-client"; import { + GatewayConnection, + GatewayError, + buildSessionKey, + fetchHistory, + listAgents, + streamChat, + type GatewayActor, + type GatewayCredentials, +} from "./gateway-client"; +import { + deriveVisitorId, memoryRateLimiter, randomId, signVisitorToken, + constantTimeEqual, + subjectDigest, verifyTurnstile, verifyVisitorToken, type RateLimiter, + type VisitorClaims, } from "./security"; +/** + * A signed-in user of the host app, as `identity.authenticate` reports it. Scopes are not part of + * the session exchange: report scope changes with `lifecycle({ event: "scopes_changed" })` from + * `@dewee/identity`. + */ +export interface AuthenticatedUser { + /** Your stable, opaque user id. Never an email address (an id containing "@" is refused). */ + subject: string; +} + +/** A private JWK whose public half is published in the application's `jwks_url`. */ +export interface IdentityPrivateJwk { + kty: string; + kid: string; + d: string; + alg?: string; + [key: string]: unknown; +} + +/** Partner assertion signer, the shape `@dewee/identity` takes. */ +export type IdentitySigner = + | { applicationId: string; issuer: string; secret: string } + | { applicationId: string; issuer: string; privateKeyJwk: IdentityPrivateJwk; algorithm?: "RS256" | "ES256" | "EdDSA" }; + +/** + * The two `@dewee/identity` calls the handler uses. Pass `deweeIdentitySdk` from + * `dewee-webchat/server/identity`; it is a separate entry so that only hosts that sign users in + * need the SDK installed and bundled. + */ +export interface DeweeIdentitySdk { + exchangeSession(opts: { + gatewayUrl: string; + allowInsecureHttp?: boolean; + signer: IdentitySigner; + subject: string; + agent: string; + ttlSeconds?: number; + }): Promise<{ sessionToken: string; principalId: string; expiresAt: Date }>; + startLink(opts: { + gatewayUrl: string; + allowInsecureHttp?: boolean; + sessionToken: string; + idempotencyKey?: string; + }): Promise<{ proofCode: string; expiresAt: Date; deepLinks: Array<{ channelType: string; channelInstanceId: string; name: string; displayName?: string }> }>; +} + +/** + * Verified signed-in users through a Dewee identity session exchange. The host passes one + * server-side callback; the handler exchanges the user for a short-lived Dewee identity session bound + * to one agent, and the gateway derives the user from it. + */ +export interface DeweeIdentityOptions { + /** `deweeIdentitySdk` from `dewee-webchat/server/identity`. */ + sdk: DeweeIdentitySdk; + /** + * Reads your own login (cookie, header…). Return null for a visitor who is not signed in. Throwing + * means the login could not be read: the visitor gets `503 SOURCE_UNAVAILABLE`, never an anonymous + * session. Keep it cheap or cached: it runs on every `/session` (every page load) and, with + * `rebindEachRequest`, on every `/chat`, `/history` and `/link`, behind a generous per-IP cap only. + */ + authenticate(req: Request): Promise; + /** Dewee application id (`dewee identity setup` prints it). */ + appId: string; + /** The issuer configured on the application, exactly. */ + issuer: string; + /** Optional check: must equal `dewee:`, the audience Dewee issued the application. */ + audience?: string; + /** Assertion secret of an `hmac` application. Exactly one of this or `privateKeyJwk`. */ + assertionSecret?: string; + /** Private signing key of a `jwks` application (RS256, ES256 or EdDSA, with `kid`). */ + privateKeyJwk?: IdentityPrivateJwk; + /** Algorithm of `privateKeyJwk`; defaults to its `alg`. */ + algorithm?: "RS256" | "ES256" | "EdDSA"; + /** The agent identity sessions are bound to. Default: `defaultAgent`, else the first of `agents`. */ + agent?: string; + /** + * Refuse visitors who are not signed in (no anonymous fallback). Default false: a visitor for whom + * `authenticate` returns null chats anonymously exactly as without this option. + */ + required?: boolean; + /** + * Check the signed-in user again on every `/chat`, `/history` and `/link`: `authenticate(req)` must + * return the same subject the session was issued for, else the request is `401 IDENTITY_REQUIRED` + * (the widget then asks `/session` again); if it throws, `503 SOURCE_UNAVAILABLE`. Recommended when + * your login is a cookie: a widget token replayed under another user's cookie is then useless. + * Default false. + */ + rebindEachRequest?: boolean; + /** Identity session lifetime, 60 to 43200 seconds. Default: the gateway default (3600). */ + sessionTtlSeconds?: number; + /** Allow plain http to a gateway that is not loopback (private networks only). */ + allowInsecureHttp?: boolean; +} + export interface DeweeHandlerOptions { /** Gateway runtime origin. Required unless every request brings its own credentials. */ gatewayUrl?: string; @@ -42,7 +156,11 @@ export interface DeweeHandlerOptions { allowClientCredentials?: boolean | { allowedHosts?: string[] }; /** Cloudflare Turnstile. When set, issuing a visitor session requires a valid challenge. */ turnstile?: { siteKey: string; secretKey: string }; - /** Custom limiter, or false to disable. Default: in-memory 20 msgs/min, 10 sessions/10 min per IP. */ + /** + * Custom limiter, or false to disable. Default (in memory): 20 messages/min per visitor and IP, + * 10 new sessions/10 min per IP and, with `identity`, 120 `authenticate` calls/min per IP, + * 30 signed-in sessions/10 min per user and 5 link starts/10 min per user. + */ rateLimit?: RateLimiter | false; maxMessageLength?: number; /** Prefix for the per-visitor gateway user id. */ @@ -52,12 +170,68 @@ export interface DeweeHandlerOptions { timeoutMs?: number; /** Observe traffic (logging/analytics). Errors thrown here are ignored. */ onChat?: (info: { visitorId: string; agent: string; message: string; reply?: string; error?: string }) => void; + /** + * Verified signed-in users through Dewee (needs the `@dewee/identity` package). Absent: every + * visitor is anonymous, exactly as before. Cannot be combined with `allowClientCredentials`. + */ + identity?: DeweeIdentityOptions; } type Handler = (request: Request) => Promise; const ID_RE = /^[A-Za-z0-9_-]{6,64}$/; const AGENT_RE = /^[A-Za-z0-9_.:-]{1,64}$/; +const IDEMPOTENCY_KEY_RE = /^[\x21-\x7E]{1,255}$/; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** HTTP status and fallback text of each identity error. The widget shows its own localized copy. */ +const IDENTITY_ERRORS: Record = { + IDENTITY_REQUIRED: { status: 401, message: "Sign in to continue.", action: "reauthenticate" }, + LINK_EXPIRED: { status: 401, message: "Your session expired.", action: "reauthenticate" }, + LINK_REVOKED: { status: 401, message: "Your access was revoked.", action: "reauthenticate" }, + IDENTITY_LINK_REQUIRED: { status: 403, message: "Link your account to continue.", action: "send_connect_link" }, + PERMISSION_DENIED: { status: 403, message: "You are not allowed to do this.", action: "contact_admin" }, + IDENTITY_CONFLICT: { status: 409, message: "This account is linked to another user.", action: "contact_admin" }, + SOURCE_UNAVAILABLE: { status: 503, message: "Your sign-in could not be checked. Try again later.", action: "retry_later" }, +}; + +/** Identity errors after which a cached identity session is dropped. */ +const DEAD_SESSION_CODES = new Set(["IDENTITY_REQUIRED", "LINK_EXPIRED", "LINK_REVOKED"]); +/** Gateway default identity session lifetime, used when the expiry cannot be trusted (clock skew). */ +const DEFAULT_IDENTITY_TTL_MS = 3600_000; +const MAX_IDENTITY_TTL_MS = 43_200_000; +/** Most identity sessions kept for reuse; each is a few hundred bytes. */ +const MAX_CACHED_SESSIONS = 1000; +/** An anonymous token this far from expiry is handed back as is instead of minting a new one. */ +const ANON_REUSE_MS = 86_400_000; + +/** Validates the identity options once, at startup, and returns the agent sessions are bound to. */ +function checkIdentityOptions(o: DeweeHandlerOptions, staticAgents: AgentInfo[]): string { + const idn = o.identity!; + const refuse = (why: string): never => { + throw new Error(`[dewee-webchat] identity: ${why}`); + }; + if (o.allowClientCredentials) refuse("cannot be combined with allowClientCredentials (a visitor-chosen gateway must never receive a user's session)"); + if (!o.gatewayUrl || !o.token) refuse("needs the server's own gatewayUrl and token"); + if (!idn.sdk || typeof idn.sdk.exchangeSession !== "function" || typeof idn.sdk.startLink !== "function") { + refuse('sdk is required: import { deweeIdentitySdk } from "dewee-webchat/server/identity" (needs the @dewee/identity package)'); + } + if (typeof idn.authenticate !== "function") refuse("authenticate(req) is required"); + if (typeof idn.appId !== "string" || !UUID_RE.test(idn.appId)) refuse("appId must be the Dewee application id (a UUID)"); + if (typeof idn.issuer !== "string" || idn.issuer.trim() === "") refuse("issuer is required"); + if (idn.audience !== undefined && String(idn.audience).toLowerCase() !== `dewee:${idn.appId.toLowerCase()}`) refuse("audience must be dewee:"); + const hasSecret = idn.assertionSecret !== undefined && idn.assertionSecret !== ""; + const hasJwk = idn.privateKeyJwk !== undefined; + if (hasSecret === hasJwk) refuse("set exactly one of assertionSecret or privateKeyJwk"); + if (hasSecret && new TextEncoder().encode(idn.assertionSecret!).length < 32) refuse("assertionSecret is too short (at least 32 bytes)"); + if (hasJwk && (typeof idn.privateKeyJwk!.d !== "string" || typeof idn.privateKeyJwk!.kid !== "string")) refuse("privateKeyJwk must be a private JWK with a kid"); + const ttl = idn.sessionTtlSeconds; + if (ttl !== undefined && (!Number.isInteger(ttl) || ttl < 60 || ttl > 43200)) refuse("sessionTtlSeconds must be an integer from 60 to 43200"); + const agent = idn.agent || o.defaultAgent || staticAgents[0]?.id; + if (!agent || !AGENT_RE.test(agent)) refuse("no agent to bind sessions to: set identity.agent, defaultAgent or agents"); + if (staticAgents.length && !staticAgents.some((a) => a.id === agent)) refuse(`agent "${agent}" is not in agents`); + return agent!; +} export function createDeweeHandler(options: DeweeHandlerOptions): Handler { const o = { @@ -73,11 +247,16 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { console.warn("[dewee-webchat] No secret configured: using an ephemeral one. Visitor sessions reset on restart."); } const limiter: RateLimiter | null = - o.rateLimit === false ? null : o.rateLimit ?? memoryRateLimiter({ chat: [20, 60_000], session: [10, 600_000] }); + o.rateLimit === false + ? null + : o.rateLimit ?? + memoryRateLimiter({ chat: [20, 60_000], session: [10, 600_000], preauth: [120, 60_000], verified: [30, 600_000], link: [5, 600_000] }); const staticAgents: AgentInfo[] = o.agents === "*" ? [] : (o.agents ?? []).map((a) => (typeof a === "string" ? { id: a } : a)); const wildcardAgents = o.agents === "*"; const byo = o.allowClientCredentials ? (o.allowClientCredentials === true ? {} : o.allowClientCredentials) : null; + const idn = o.identity; + const identityAgent = idn ? checkIdentityOptions(o, staticAgents) : ""; function serverCreds(): GatewayCredentials | null { return o.gatewayUrl && o.token ? { url: o.gatewayUrl, token: o.token, tenantId: o.tenantId } : null; @@ -142,14 +321,195 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { }); const fail = (req: Request, status: number, message: string, code = "bad_request") => json(req, { error: { message, code } }, status); + /** A typed identity error: `{error: {message, code, remediation}}`. Never carries a token or secret. */ + const identityFail = (req: Request, code: IdentityErrorCode, remediation?: Remediation) => { + const e = IDENTITY_ERRORS[code]; + return json(req, { error: { message: e.message, code, remediation: remediation ?? { action: e.action, audience: "end_user" } } }, e.status); + }; + + const misconfigured = (req: Request) => fail(req, 500, "Signed-in chat is misconfigured on this server", "identity_misconfigured"); + + /** + * Gateway WebSocket failure: identity codes are forwarded typed, anything else stays a gateway error. + * A dead identity session is dropped from the reuse cache. + */ + const gatewayFail = (req: Request, e: unknown, sessionToken?: string) => { + if (!(e instanceof GatewayError && isIdentityErrorCode(e.code))) return fail(req, 502, (e as Error).message, "gateway_error"); + if (sessionToken && DEAD_SESSION_CODES.has(e.code)) forgetCachedSession(sessionToken); + if (e.remediation?.audience === "operator") console.error(`[dewee-webchat] identity: ${e.message} (${e.code})`); + return identityFail(req, e.code, e.remediation); + }; + + /** `@dewee/identity` failure (exchange, link): typed identity codes are forwarded; SDK messages are not. */ + const sdkFail = (req: Request, e: unknown, what: string) => { + const code = (e as { code?: unknown })?.code; + if (isIdentityErrorCode(code)) { + console.warn(`[dewee-webchat] identity ${what} refused: ${code}`); + return identityFail(req, code, (e as { remediation?: Remediation }).remediation); + } + console.warn(`[dewee-webchat] identity ${what} failed:`, code ?? e); + if (code === "RESOURCE_EXHAUSTED") return fail(req, 429, "Too many sign-ins, slow down", "rate_limited"); + if (code === "INVALID_ARGUMENT" || code === "SERVER_ONLY") return misconfigured(req); + return fail(req, 502, "Dewee is unavailable, try again later", "gateway_error"); + }; + + /** + * Exchange failure. Only an error the signed-in user can act on reaches the browser typed; one + * addressed to the operator or integrator (an application or agent set up wrong, exchange switched + * off on the gateway) is this server's misconfiguration. + */ + const exchangeFail = (req: Request, e: unknown) => { + const { code, remediation } = (e ?? {}) as { code?: unknown; remediation?: Remediation }; + if ((isIdentityErrorCode(code) && remediation && remediation.audience !== "end_user") || code === "NOT_FOUND") { + console.error(`[dewee-webchat] identity session exchange refused: ${String(code)} (remediation for ${remediation?.audience ?? "operator"}: ${remediation?.action ?? "check the gateway"})`); + return misconfigured(req); + } + return sdkFail(req, e, "session exchange"); + }; + + /** Link start failure: the gateway's conflict and availability codes get their own answers. */ + const linkFail = (req: Request, e: unknown, sessionToken: string) => { + const code = (e as { code?: unknown })?.code; + if (code === "IDEMPOTENCY_KEY_REUSED") return fail(req, 409, "This idempotency key was already used for a different link request", "idempotency_key_reused"); + if (code === "FAILED_PRECONDITION") return fail(req, 409, "A link is already in progress. Use its code or wait until it expires.", "link_in_progress"); + if (code === "NOT_FOUND") { + console.error("[dewee-webchat] identity link start: the gateway has identity exchange switched off"); + return fail(req, 503, "Linking is not available right now", "linking_unavailable"); + } + if (typeof code === "string" && DEAD_SESSION_CODES.has(code)) forgetCachedSession(sessionToken); + return sdkFail(req, e, "link start"); + }; + const clientIp = (req: Request) => req.headers.get("cf-connecting-ip") || req.headers.get("x-real-ip") || req.headers.get("x-forwarded-for")?.split(",")[0].trim() || "unknown"; - async function visitorFrom(req: Request): Promise { + /** The verified visitor token of the request. A token carrying an identity session is honored only in identity mode. */ + async function visitorFrom(req: Request): Promise { const auth = req.headers.get("authorization") || ""; const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; const claims = token ? await verifyVisitorToken(secret!, token) : null; - return claims?.v ?? null; + if (claims?.ids && !idn) return null; + return claims; + } + + /** + * Agent of a request: an identity session is bound to its agent, which must still be allowed by the + * current `agents`; anything else follows the allowlist. + */ + async function agentFor(claims: VisitorClaims, requested: string | null | undefined, creds: GatewayCredentials | null, isByo: boolean): Promise { + if (claims.ids) { + const bound = claims.ida!; + if (requested && requested !== bound) return null; + return wildcardAgents || staticAgents.some((a) => a.id === bound) ? bound : null; + } + return pickAgent(requested, creds, isByo); + } + + /** + * With `rebindEachRequest`: the request's own login must still be the user the token was issued for. + * Returns the refusal, or null to go on. + */ + async function rebindRefusal(req: Request, claims: VisitorClaims): Promise { + if (!idn?.rebindEachRequest || !claims.ids) return null; + const throttled = await preauthRefusal(req); + if (throttled) return throttled; + let user: AuthenticatedUser | null; + try { + user = await idn.authenticate(req); + } catch (e) { + console.warn("[dewee-webchat] identity.authenticate failed:", e); + return identityFail(req, "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "end_user" }); + } + const same = + !!user && typeof user.subject === "string" && !!claims.sub && constantTimeEqual(await subjectDigest(secret!, user.subject), claims.sub); + return same ? null : identityFail(req, "IDENTITY_REQUIRED"); + } + + /** A generous per-IP cap in front of every `authenticate` call, so a client cannot drive the host's login check at will. */ + async function preauthRefusal(req: Request): Promise { + if (!limiter || (await limiter(clientIp(req), "preauth"))) return null; + return fail(req, 429, "Too many requests, slow down", "rate_limited"); + } + + const actorOf = (claims: VisitorClaims): GatewayActor => (claims.ids ? { identitySession: claims.ids } : o.userIdPrefix + claims.v); + + function identitySigner(): IdentitySigner { + if (idn!.assertionSecret) return { applicationId: idn!.appId, issuer: idn!.issuer, secret: idn!.assertionSecret }; + return { applicationId: idn!.appId, issuer: idn!.issuer, privateKeyJwk: idn!.privateKeyJwk!, algorithm: idn!.algorithm }; + } + + /** + * One identity session per user (and bound agent), reused until it nears expiry, so a page load does + * not cost an exchange. Bounded; a session the gateway reports dead is dropped. + */ + interface CachedSession { sessionToken: string; visitorId: string; deadline: number; margin: number } + const sessionCache = new Map(); + const exchanges = new Map>(); + + function forgetCachedSession(sessionToken: string): void { + for (const [k, c] of sessionCache) if (c.sessionToken === sessionToken) sessionCache.delete(k); + } + + function rememberSession(key: string, c: CachedSession): void { + sessionCache.delete(key); + sessionCache.set(key, c); + if (sessionCache.size <= MAX_CACHED_SESSIONS) return; + const now = Date.now(); + for (const [k, v] of sessionCache) if (v.deadline - now <= v.margin) sessionCache.delete(k); + // Still full: drop the least recently issued. + for (const k of sessionCache.keys()) { + if (sessionCache.size <= MAX_CACHED_SESSIONS) break; + sessionCache.delete(k); + } + } + + async function exchangeFor(subject: string): Promise { + const session = await idn!.sdk.exchangeSession({ + gatewayUrl: o.gatewayUrl!, + allowInsecureHttp: idn!.allowInsecureHttp, + signer: identitySigner(), + subject, + agent: identityAgent, + ttlSeconds: idn!.sessionTtlSeconds, + }); + // Lifetime is measured from receipt on this server's clock. The gateway's absolute expiry is only + // trusted while it fits the requested lifetime; otherwise the clocks disagree and the requested + // (or default) lifetime is used. + const receivedAt = Date.now(); + const bound = idn!.sessionTtlSeconds ? idn!.sessionTtlSeconds * 1000 : MAX_IDENTITY_TTL_MS; + let lifetime = session.expiresAt.getTime() - receivedAt; + if (!(lifetime > 0 && lifetime <= bound)) { + console.warn("[dewee-webchat] identity session expiry does not match this server's clock; using the requested lifetime"); + lifetime = idn!.sessionTtlSeconds ? idn!.sessionTtlSeconds * 1000 : DEFAULT_IDENTITY_TTL_MS; + } + // Same principal, same visitor id: conversations follow the user across devices and page loads. + const visitorId = await deriveVisitorId(secret!, `principal:${session.principalId}`); + return { sessionToken: session.sessionToken, visitorId, deadline: receivedAt + lifetime, margin: Math.min(60_000, lifetime / 2) }; + } + + /** A signed-in user's identity session, reused or exchanged, wrapped in a visitor token. */ + async function issueIdentitySession(req: Request, subject: string): Promise { + const key = `${identityAgent}\n${subject}`; + let s = sessionCache.get(key); + if (!s || s.deadline - Date.now() <= s.margin) { + let pending = exchanges.get(key); + if (!pending) { + pending = exchangeFor(subject).finally(() => exchanges.delete(key)); + exchanges.set(key, pending); + } + try { + s = await pending; + } catch (e) { + return exchangeFail(req, e); + } + rememberSession(key, s); + } + const now = Date.now(); + const expiresAt = Math.min(s.deadline, now + o.sessionTtlMs); + const sub = await subjectDigest(secret!, subject); + const token = await signVisitorToken(secret!, { v: s.visitorId, exp: expiresAt, ids: s.sessionToken, ida: identityAgent, sub }); + const body: SessionResponse = { visitorId: s.visitorId, token, expiresAt, verified: true, expiresIn: expiresAt - now }; + return json(req, body); } async function pickAgent(requested: string | null | undefined, creds: GatewayCredentials | null, isByo: boolean): Promise { @@ -180,28 +540,85 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { maxMessageLength: o.maxMessageLength, version: VERSION, }; + if (idn) { + // Identity sessions are bound to one agent, so the widget must not switch. + body.defaultAgent = identityAgent; + body.allowAgentSwitch = false; + body.identity = true; + } return json(req, body); } + /** Cloudflare Turnstile, when configured. */ + async function turnstilePassed(body: any, ip: string): Promise { + if (!o.turnstile) return true; + return !!body?.turnstileToken && (await verifyTurnstile(o.turnstile.secretKey, String(body.turnstileToken), ip)); + } + async function handleSession(req: Request): Promise { + if (idn) return handleIdentitySession(req); const ip = clientIp(req); if (limiter && !(await limiter(ip, "session"))) return fail(req, 429, "Too many sessions, slow down", "rate_limited"); const body: any = await req.json().catch(() => ({})); - if (o.turnstile) { - if (!body?.turnstileToken || !(await verifyTurnstile(o.turnstile.secretKey, String(body.turnstileToken), ip))) { - return fail(req, 403, "Verification failed", "turnstile_failed"); - } - } + return issueAnonymousSession(req, body, ip); + } + + async function issueAnonymousSession(req: Request, body: any, ip: string): Promise { + if (!(await turnstilePassed(body, ip))) return fail(req, 403, "Verification failed", "turnstile_failed"); // Refresh an existing visitor instead of minting a new identity, so history survives. + // A signed-in user's token never seeds an anonymous visitor. const previous = typeof body?.token === "string" ? await verifyVisitorToken(secret!, body.token) : null; - const visitorId = previous?.v ?? randomId(16); + const visitorId = (previous && !previous.ids ? previous.v : undefined) ?? randomId(16); const expiresAt = Date.now() + o.sessionTtlMs; return json(req, { visitorId, token: await signVisitorToken(secret!, { v: visitorId, exp: expiresAt }), expiresAt }); } + /** + * `/session` of a backend that signs users in. The widget calls it on every page load, so: + * a signed-in user is limited per user (not per IP) and reuses a cached identity session; a visitor + * who is not signed in gets a still-valid anonymous token back as is, and only minting a new one + * counts against the per-IP session limit. + */ + async function handleIdentitySession(req: Request): Promise { + const ip = clientIp(req); + const body: any = await req.json().catch(() => ({})); + // Fail closed: a signed-in user gets an identity session or no session at all. A login that could + // not be read is not "signed out", so it never falls back to anonymous. + const throttled = await preauthRefusal(req); + if (throttled) return throttled; + let user: AuthenticatedUser | null; + try { + user = await idn!.authenticate(req); + } catch (e) { + console.warn("[dewee-webchat] identity.authenticate failed:", e); + return identityFail(req, "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "end_user" }); + } + if (user != null) { + if (typeof user !== "object" || typeof user.subject !== "string" || user.subject.trim() === "") { + console.error("[dewee-webchat] identity.authenticate must return { subject } or null"); + return misconfigured(req); + } + if (limiter && !(await limiter(`subject:${user.subject}`, "verified"))) return fail(req, 429, "Too many sessions, slow down", "rate_limited"); + if (!(await turnstilePassed(body, ip))) return fail(req, 403, "Verification failed", "turnstile_failed"); + return issueIdentitySession(req, user.subject); + } + if (idn!.required) return identityFail(req, "IDENTITY_REQUIRED"); + const previous = typeof body?.token === "string" ? await verifyVisitorToken(secret!, body.token) : null; + if (previous && !previous.ids && previous.exp - Date.now() > ANON_REUSE_MS) { + const reused: SessionResponse = { visitorId: previous.v, token: body.token, expiresAt: previous.exp }; + return json(req, reused); + } + if (limiter && !(await limiter(ip, "session"))) return fail(req, 429, "Too many sessions, slow down", "rate_limited"); + return issueAnonymousSession(req, body, ip); + } + async function handleChat(req: Request): Promise { - const visitorId = await visitorFrom(req); - if (!visitorId) return fail(req, 401, "Missing or expired visitor session", "unauthorized"); + const claims = await visitorFrom(req); + if (!claims) return fail(req, 401, "Missing or expired visitor session", "unauthorized"); + if (idn?.required && !claims.ids) return identityFail(req, "IDENTITY_REQUIRED"); + const refused = await rebindRefusal(req, claims); + if (refused) return refused; + const visitorId = claims.v; if (limiter && !(await limiter(`${visitorId}|${clientIp(req)}`, "chat"))) return fail(req, 429, "Too many messages, please wait a moment", "rate_limited"); const { creds, byo: isByo, error } = resolveCreds(req); if (error) return fail(req, 400, error, "bad_credentials"); @@ -212,7 +629,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { if (!message) return fail(req, 400, "Message is required"); if (message.length > o.maxMessageLength) return fail(req, 413, `Message exceeds ${o.maxMessageLength} characters`, "too_long"); const conversationId = typeof body?.conversationId === "string" && ID_RE.test(body.conversationId) ? body.conversationId : randomId(9); - const agent = await pickAgent(body?.agent, creds, isByo); + const agent = await agentFor(claims, body?.agent, creds, isByo); if (!agent) return fail(req, 403, "Agent not allowed", "agent_not_allowed"); // Page actions: the manifest (no handlers) and page context become a delimited block in front of the @@ -230,6 +647,17 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { throw e; } + // A signed-in user's session is checked before streaming starts, so an expired or revoked + // session answers with a typed HTTP error the widget can act on (re-request /session). + let connection: GatewayConnection | undefined; + if (claims.ids) { + try { + connection = await GatewayConnection.connect(creds, { identitySession: claims.ids }); + } catch (e) { + return gatewayFail(req, e, claims.ids); + } + } + const sessionKey = buildSessionKey(agent, `webchat-${visitorId}-${conversationId}`); const abort = new AbortController(); req.signal?.addEventListener("abort", () => abort.abort(), { once: true }); @@ -245,10 +673,14 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { let errMsg: string | undefined; try { await send(actionsHash ? { type: "start", conversationId, agent, actionsHash } : { type: "start", conversationId, agent }); - for await (const ev of streamChat({ creds, userId: o.userIdPrefix + visitorId, agent, sessionKey, message: pageBlock + message, signal: abort.signal, timeoutMs: o.timeoutMs })) { + const who = connection ? { connection } : { userId: o.userIdPrefix + visitorId }; + for await (const ev of streamChat({ creds, ...who, agent, sessionKey, message: pageBlock + message, signal: abort.signal, timeoutMs: o.timeoutMs })) { if (ev.type === "start") continue; // already announced with the conversation id if (ev.type === "done") reply = ev.content; - if (ev.type === "error") errMsg = ev.message; + if (ev.type === "error") { + errMsg = ev.message; + if (claims.ids && ev.code && DEAD_SESSION_CODES.has(ev.code)) forgetCachedSession(claims.ids); + } await send(ev); if (abort.signal.aborted) break; } @@ -256,6 +688,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { errMsg = (e as Error).message; await send({ type: "error", message: errMsg, code: "gateway_error" }); } finally { + connection?.close(); // idempotent; covers a turn that ended before the stream took the connection clearInterval(keepAlive); try { o.onChat?.({ visitorId, agent, message, reply, error: errMsg }); } catch { /* observer errors are ignored */ } writer.close().catch(() => {}); @@ -273,23 +706,53 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { } async function handleHistory(req: Request): Promise { - const visitorId = await visitorFrom(req); - if (!visitorId) return fail(req, 401, "Missing or expired visitor session", "unauthorized"); + const claims = await visitorFrom(req); + if (!claims) return fail(req, 401, "Missing or expired visitor session", "unauthorized"); + if (idn?.required && !claims.ids) return identityFail(req, "IDENTITY_REQUIRED"); + const refused = await rebindRefusal(req, claims); + if (refused) return refused; + const visitorId = claims.v; const { creds, byo: isByo, error } = resolveCreds(req); if (error) return fail(req, 400, error, "bad_credentials"); if (!creds) return fail(req, 500, "Gateway is not configured", "not_configured"); const url = new URL(req.url); const conversationId = url.searchParams.get("conversationId") || ""; if (!ID_RE.test(conversationId)) return fail(req, 400, "Invalid conversationId"); - const agent = await pickAgent(url.searchParams.get("agent"), creds, isByo); + const agent = await agentFor(claims, url.searchParams.get("agent"), creds, isByo); if (!agent) return fail(req, 403, "Agent not allowed", "agent_not_allowed"); try { - const messages = await fetchHistory(creds, o.userIdPrefix + visitorId, agent, buildSessionKey(agent, `webchat-${visitorId}-${conversationId}`)); + const messages = await fetchHistory(creds, actorOf(claims), agent, buildSessionKey(agent, `webchat-${visitorId}-${conversationId}`)); return json(req, { messages }); } catch (e) { // A conversation that has not sent its first message yet does not exist on the gateway. if ((e as GatewayError).code === "NOT_FOUND") return json(req, { messages: [] }); - return fail(req, 502, (e as Error).message, "gateway_error"); + return gatewayFail(req, e, claims.ids); + } + } + + /** Web-first linking: starts a Dewee link for the signed-in user and returns the proof code. */ + async function handleLink(req: Request): Promise { + const claims = await visitorFrom(req); + if (!claims?.ids) return identityFail(req, "IDENTITY_REQUIRED"); + const refused = await rebindRefusal(req, claims); + if (refused) return refused; + // Its own bucket, per user: linking never eats into the session budget, and the reverse. + if (limiter && !(await limiter(`visitor:${claims.v}`, "link"))) return fail(req, 429, "Too many link requests, slow down", "rate_limited"); + const body: any = await req.json().catch(() => ({})); + const idempotencyKey = body?.idempotencyKey; + if (idempotencyKey !== undefined && (typeof idempotencyKey !== "string" || !IDEMPOTENCY_KEY_RE.test(idempotencyKey))) { + return fail(req, 400, "idempotencyKey must be 1 to 255 printable ASCII characters without spaces"); + } + try { + const link = await idn!.sdk.startLink({ gatewayUrl: o.gatewayUrl!, allowInsecureHttp: idn!.allowInsecureHttp, sessionToken: claims.ids, idempotencyKey }); + const res: LinkResponse = { + proofCode: link.proofCode, + expiresAt: link.expiresAt.getTime(), + deepLinks: link.deepLinks.map((t) => ({ channelType: t.channelType, channelInstanceId: t.channelInstanceId, name: t.name, ...(t.displayName ? { displayName: t.displayName } : {}) })), + }; + return json(req, res); + } catch (e) { + return linkFail(req, e, claims.ids); } } @@ -302,6 +765,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { if (req.method === "POST" && action === "session") return await handleSession(req); if (req.method === "POST" && action === "chat") return await handleChat(req); if (req.method === "GET" && action === "history") return await handleHistory(req); + if (req.method === "POST" && action === "link" && idn) return await handleLink(req); return fail(req, 404, "Not found", "not_found"); } catch (e) { return fail(req, 500, (e as Error).message || "Internal error", "internal"); diff --git a/packages/dewee-webchat/src/server/identity.ts b/packages/dewee-webchat/src/server/identity.ts new file mode 100644 index 0000000..dd7eb7f --- /dev/null +++ b/packages/dewee-webchat/src/server/identity.ts @@ -0,0 +1,13 @@ +// `dewee-webchat/server/identity`: the `@dewee/identity` calls the handler needs for signed-in users. +// A separate entry so that `dewee-webchat/server` never references the SDK and hosts that only serve +// anonymous visitors can bundle it without the SDK installed. +// +// import { createDeweeHandler } from "dewee-webchat/server"; +// import { deweeIdentitySdk } from "dewee-webchat/server/identity"; +// createDeweeHandler({ ..., identity: { sdk: deweeIdentitySdk, authenticate, appId, issuer, assertionSecret } }); +import { exchangeSession, startLink } from "@dewee/identity"; +import type { DeweeIdentitySdk } from "./handler"; + +export const deweeIdentitySdk: DeweeIdentitySdk = { exchangeSession, startLink }; + +export type { DeweeIdentitySdk } from "./handler"; diff --git a/packages/dewee-webchat/src/server/index.ts b/packages/dewee-webchat/src/server/index.ts index 5692fc8..bdda6a6 100644 --- a/packages/dewee-webchat/src/server/index.ts +++ b/packages/dewee-webchat/src/server/index.ts @@ -1,4 +1,13 @@ -export { createDeweeHandler, optionsFromEnv, type DeweeHandlerOptions } from "./handler"; +export { + createDeweeHandler, + optionsFromEnv, + type AuthenticatedUser, + type DeweeHandlerOptions, + type DeweeIdentityOptions, + type DeweeIdentitySdk, + type IdentityPrivateJwk, + type IdentitySigner, +} from "./handler"; export { toNodeHandler } from "./node"; export { GatewayConnection, @@ -8,9 +17,32 @@ export { foldHistory, listAgents, streamChat, + type GatewayActor, type GatewayCredentials, type StreamChatOptions, } from "./gateway-client"; -export { memoryRateLimiter, signVisitorToken, verifyVisitorToken, type RateLimiter } from "./security"; +export { + memoryRateLimiter, + signVisitorToken, + verifyVisitorToken, + type MemoryRateLimits, + type RateLimitBucket, + type RateLimiter, + type VisitorClaims, +} from "./security"; export { ACTION_LIMITS, buildPageBlock, hashManifest, normalizeManifest, stripPageBlock } from "../actions/page-block"; -export type { ActionManifestEntry, ActionSensitivity, AgentInfo, ChatRequestBody, ChatStreamEvent, HistoryMessage, PublicConfig, SessionResponse } from "../protocol"; +export type { + ActionManifestEntry, + ActionSensitivity, + AgentInfo, + ChatRequestBody, + ChatStreamEvent, + ErrorBody, + HistoryMessage, + IdentityErrorCode, + LinkResponse, + LinkTarget, + PublicConfig, + Remediation, + SessionResponse, +} from "../protocol"; diff --git a/packages/dewee-webchat/src/server/security.ts b/packages/dewee-webchat/src/server/security.ts index 4bc22d9..eb58063 100644 --- a/packages/dewee-webchat/src/server/security.ts +++ b/packages/dewee-webchat/src/server/security.ts @@ -24,14 +24,108 @@ function hmacKey(secret: string): Promise { } export interface VisitorClaims { - /** Visitor id (random, 22 chars). */ + /** Visitor id (random, 22 chars; derived from the principal for a signed-in user). */ v: string; /** Expiry, epoch ms. */ exp: number; + /** + * Signed-in users only: the Dewee identity session token. In the signed token it is encrypted + * (AES-256-GCM), so the browser only ever holds an opaque value; these claims carry it decrypted. + */ + ids?: string; + /** Signed-in users only: the agent the identity session is bound to. */ + ida?: string; + /** + * Signed-in users only: a keyed digest of the host's subject (see {@link subjectDigest}). Sealed + * together with `ids`, so the browser never sees the subject or its digest. + */ + sub?: string; +} + +/** Longest identity session token the gateway issues (matches the `@dewee/identity` limit). */ +const MAX_IDENTITY_SESSION_LENGTH = 512; +/** + * Longest sealed plaintext, `{"t":"","h":""}` in UTF-8: the 15 bytes of JSON syntax, + * the session at worst 6 bytes per character (a `\u00XX` escape; a non-ASCII character is at most 3 + * UTF-8 bytes per UTF-16 unit), and the 43-character base64url HMAC-SHA-256 digest. + */ +const MAX_SEALED_PLAINTEXT = '{"t":"","h":""}'.length + 6 * MAX_IDENTITY_SESSION_LENGTH + 43; +/** Longest sealed form: 12-byte IV + plaintext + 16-byte tag, base64url. */ +const MAX_SEALED_LENGTH = Math.ceil(((12 + MAX_SEALED_PLAINTEXT + 16) * 4) / 3); +const AGENT_CLAIM_RE = /^[A-Za-z0-9_.:-]{1,64}$/; + +const sealKeyCache = new Map>(); +/** AES-256-GCM key for identity sessions, derived from the visitor secret with its own HKDF label. */ +function sealKey(secret: string): Promise { + let k = sealKeyCache.get(secret); + if (!k) { + k = crypto.subtle + .importKey("raw", enc.encode(secret), "HKDF", false, ["deriveKey"]) + .then((base) => + crypto.subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: enc.encode("dewee-webchat"), info: enc.encode("dewee-webchat:identity-session:aes-256-gcm:v1") }, + base, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ), + ); + sealKeyCache.set(secret, k); + } + return k; +} + +/** The ciphertext is bound to the visitor and agent it was issued for. */ +const sealAad = (v: string, agent: string) => enc.encode(`dewee-webchat:ids:${v}:${agent}`); + +/** What is sealed: the session token and, optionally, the subject digest. */ +interface SealedIdentity { t: string; h?: string } + +async function sealIdentitySession(secret: string, v: string, agent: string, sealed: SealedIdentity): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: sealAad(v, agent) }, await sealKey(secret), enc.encode(JSON.stringify(sealed)))); + const out = new Uint8Array(iv.length + ct.length); + out.set(iv); + out.set(ct, iv.length); + return b64url(out); +} + +async function openIdentitySession(secret: string, v: string, agent: string, sealed: string): Promise { + const raw = fromB64url(sealed); + if (raw.length <= 12 + 16) return null; + try { + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: raw.slice(0, 12), additionalData: sealAad(v, agent) }, await sealKey(secret), raw.slice(12)); + const parsed = JSON.parse(new TextDecoder().decode(pt)) as SealedIdentity; + if (typeof parsed?.t !== "string" || parsed.t === "" || parsed.t.length > MAX_IDENTITY_SESSION_LENGTH) return null; + if (parsed.h !== undefined && typeof parsed.h !== "string") return null; + return parsed; + } catch { + return null; + } +} + +/** Keyed digest of a host subject, for binding a token to the user it was issued for without storing the subject. */ +export async function subjectDigest(secret: string, subject: string): Promise { + const mac = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(`dewee-webchat:subject:${subject}`))); + return b64url(mac); +} + +/** Compares two strings in time that depends only on their lengths, not on where they differ. */ +export function constantTimeEqual(a: string, b: string): boolean { + const x = enc.encode(a); + const y = enc.encode(b); + let diff = x.length ^ y.length; + for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0); + return diff === 0; } export async function signVisitorToken(secret: string, claims: VisitorClaims): Promise { - const body = b64url(enc.encode(JSON.stringify(claims))); + let wire: Omit = claims; + if (claims.ids !== undefined && claims.ida !== undefined) { + const { sub, ...rest } = claims; + wire = { ...rest, ids: await sealIdentitySession(secret, claims.v, claims.ida, sub ? { t: claims.ids, h: sub } : { t: claims.ids }) }; + } + const body = b64url(enc.encode(JSON.stringify(wire))); const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(body))); return `${body}.${b64url(sig)}`; } @@ -45,27 +139,67 @@ export async function verifyVisitorToken(secret: string, token: string): Promise const claims = JSON.parse(new TextDecoder().decode(fromB64url(body))) as VisitorClaims; if (typeof claims.v !== "string" || !/^[A-Za-z0-9_-]{8,64}$/.test(claims.v)) return null; if (typeof claims.exp !== "number" || claims.exp < Date.now()) return null; + // An identity claim is all or nothing: a session token always travels with the agent it is bound to. + if (claims.ids !== undefined || claims.ida !== undefined) { + if (typeof claims.ids !== "string" || claims.ids === "" || claims.ids.length > MAX_SEALED_LENGTH) return null; + if (typeof claims.ida !== "string" || !AGENT_CLAIM_RE.test(claims.ida)) return null; + if ((claims as { sub?: unknown }).sub !== undefined) return null; // the digest only ever travels sealed + const opened = await openIdentitySession(secret, claims.v, claims.ida, claims.ids); + if (!opened) return null; + return opened.h ? { v: claims.v, exp: claims.exp, ids: opened.t, ida: claims.ida, sub: opened.h } : { v: claims.v, exp: claims.exp, ids: opened.t, ida: claims.ida }; + } return claims; } catch { return null; } } +/** + * Stable visitor id for a label (e.g. a Dewee principal id): HMAC(secret, label), 22 base64url chars. + * A signed-in user keeps the same conversations on every device and page load without the id being guessable. + */ +export async function deriveVisitorId(secret: string, label: string): Promise { + const mac = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(`dewee-webchat:visitor:${label}`))); + return b64url(mac.slice(0, 16)); +} + export function randomId(bytes = 16): string { return b64url(crypto.getRandomValues(new Uint8Array(bytes))); } +/** + * Rate-limit buckets: `session` (new visitor sessions, per IP), `chat` (messages, per visitor and IP), + * and with signed-in users `preauth` (calls to the host's `authenticate`, per IP), `verified` + * (signed-in `/session` calls, per user) and `link` (link starts, per user). + */ +export type RateLimitBucket = "session" | "chat" | "preauth" | "verified" | "link"; + +/** Default `preauth` limit: generous, it only caps how hard a client can drive `authenticate`. */ +const DEFAULT_PREAUTH_LIMIT: [number, number] = [120, 60_000]; + /** Returns true when the key may proceed. Implementations may be async (KV, DO, Redis…). */ -export type RateLimiter = (key: string, bucket: "session" | "chat") => boolean | Promise; +export type RateLimiter = (key: string, bucket: RateLimitBucket) => boolean | Promise; + +/** + * `[max, windowMs]` per bucket. `preauth` defaults to 120 per minute; `verified` and `link` fall back to + * the `session` limit when not set. + */ +export interface MemoryRateLimits { + chat: [number, number]; + session: [number, number]; + preauth?: [number, number]; + verified?: [number, number]; + link?: [number, number]; +} /** * Fixed-window in-memory limiter. Good enough for a single Node process; on serverless * runtimes each isolate keeps its own counters, so pass a shared limiter for hard limits. */ -export function memoryRateLimiter(limits: { chat: [number, number]; session: [number, number] }): RateLimiter { +export function memoryRateLimiter(limits: MemoryRateLimits): RateLimiter { const windows = new Map(); return (key, bucket) => { - const [max, windowMs] = limits[bucket]; + const [max, windowMs] = limits[bucket] ?? (bucket === "preauth" ? DEFAULT_PREAUTH_LIMIT : limits.session); const id = `${bucket}:${key}`; const now = Date.now(); const w = windows.get(id); diff --git a/packages/dewee-webchat/src/widget/api-client.ts b/packages/dewee-webchat/src/widget/api-client.ts index 0e7b77c..3f62eeb 100644 --- a/packages/dewee-webchat/src/widget/api-client.ts +++ b/packages/dewee-webchat/src/widget/api-client.ts @@ -5,10 +5,19 @@ import { type ChatRequestBody, type ChatStreamEvent, type HistoryMessage, + type LinkResponse, type PublicConfig, + type Remediation, type SessionResponse, } from "../protocol"; +/** Identity errors after which the signed-in session is dropped and `/session` is requested again. */ +const RENEW_CODES = new Set(["LINK_EXPIRED", "LINK_REVOKED"]); +/** Refresh margin of anonymous visitor tokens (they live 30 days). */ +const ANON_REFRESH_MS = 86_400_000; +/** Signed-in sessions live minutes to hours: refresh this long before expiry, at most half the lifetime. */ +const VERIFIED_REFRESH_MS = 60_000; + export interface ApiClientOptions { /** Backend base URL, e.g. "/api/dewee" or "https://example.com/api/dewee". */ endpoint: string; @@ -20,7 +29,7 @@ export interface ApiClientOptions { } export class ApiError extends Error { - constructor(message: string, public status: number, public code?: string) { + constructor(message: string, public status: number, public code?: string, public remediation?: Remediation) { super(message); } } @@ -60,7 +69,11 @@ export class ApiClient { private async parse(res: Response): Promise { const body: any = await res.json().catch(() => ({})); - if (!res.ok) throw new ApiError(body?.error?.message || `HTTP ${res.status}`, res.status, body?.error?.code); + if (!res.ok) { + const r = body?.error?.remediation; + const remediation = r && typeof r.action === "string" && typeof r.audience === "string" ? { action: r.action, audience: r.audience } : undefined; + throw new ApiError(body?.error?.message || `HTTP ${res.status}`, res.status, body?.error?.code, remediation); + } return body as T; } @@ -70,26 +83,61 @@ export class ApiClient { return this.config; } + /** When each signed-in session should be refreshed, on this browser's clock. */ + private refreshAt = new WeakMap(); + + private fresh(s: SessionResponse): boolean { + if (s.verified) return Date.now() < (this.refreshAt.get(s) ?? 0); + return s.expiresAt - Date.now() > ANON_REFRESH_MS; + } + + /** + * A signed-in session is refreshed `min(60s, lifetime/2)` before it ends, with the lifetime the + * backend reports (`expiresIn`) counted from receipt, so a short lifetime is not refreshed on every + * request and a skewed browser clock does not matter. + */ + private scheduleRefresh(s: SessionResponse): void { + const now = Date.now(); + const life = typeof s.expiresIn === "number" && s.expiresIn > 0 ? s.expiresIn : s.expiresAt - now; + this.refreshAt.set(s, now + life - Math.min(VERIFIED_REFRESH_MS, life / 2)); + } + + /** The stored anonymous visitor session, if any. Signed-in sessions are never stored. */ + private stored(): SessionResponse | null { + const saved = store.get(this.storageKey); + if (!saved) return null; + try { + const s = JSON.parse(saved) as SessionResponse; + return s.verified ? null : s; + } catch { + return null; // corrupted entry + } + } + /** Returns a valid visitor session, creating or refreshing it when needed. */ ensureSession(): Promise { - if (this.session && this.session.expiresAt - Date.now() > 86_400_000) return Promise.resolve(this.session); + if (this.session && this.fresh(this.session)) return Promise.resolve(this.session); + return (this.sessionPromise ??= this.loadSession().finally(() => (this.sessionPromise = null))); + } + + private async loadSession(): Promise { if (!this.session) { - const saved = store.get(this.storageKey); - if (saved) { - try { - const s = JSON.parse(saved) as SessionResponse; - if (s.expiresAt - Date.now() > 86_400_000) return Promise.resolve((this.session = s)); - this.session = s; // close to expiry: refresh below, keeping the same visitor id - } catch { /* corrupted entry */ } + const s = this.stored(); + if (s) { + // A backend that signs users in decides who the visitor is on every page load: the + // stored anonymous token only seeds the visitor id when the user is not signed in. + if (this.fresh(s) && !(await this.getConfig()).identity) return (this.session = s); + this.session = s; // close to expiry: refresh below, keeping the same visitor id } } - return (this.sessionPromise ??= this.createSession().finally(() => (this.sessionPromise = null))); + return this.createSession(); } private async createSession(): Promise { const cfg = await this.getConfig(); const body: Record = {}; - if (this.session?.token) body.token = this.session.token; + const previous = this.session && !this.session.verified ? this.session : this.stored(); + if (previous?.token) body.token = previous.token; if (cfg.turnstileSiteKey) { if (!this.o.turnstile) throw new ApiError("Verification is required but no Turnstile handler is set", 403, "turnstile_required"); body.turnstileToken = await this.o.turnstile(cfg.turnstileSiteKey); @@ -98,21 +146,57 @@ export class ApiClient { await fetch(this.url("session"), { method: "POST", headers: this.headers(), body: JSON.stringify(body) }), ); this.session = s; - store.set(this.storageKey, JSON.stringify(s)); + // A signed-in session stays in memory only: the next page load asks the backend again, so a + // sign-out or a different user never inherits it. + if (s.verified) this.scheduleRefresh(s); + else store.set(this.storageKey, JSON.stringify(s)); return s; } + /** Drops the session; the next request creates a new one. A stored anonymous visitor is kept for a signed-in session. */ forgetSession(): void { + if (this.session?.verified) { + this.session = null; + return; + } this.session = null; store.del(this.storageKey); } + /** + * Asks the backend for a new session now (after a sign-in or sign-out on the host page). The + * stored anonymous visitor id is kept, so an anonymous visitor's history survives. + */ + async renewSession(): Promise { + this.session = null; + return this.ensureSession(); + } + + /** Starts a web-first link of a chat account for the signed-in user (identity backends only). */ + async link(): Promise { + const res = await this.signedInRequest(() => fetch(this.url("link"), { method: "POST", headers: this.headers(true), body: "{}" })); + return this.parse(res); + } + async history(agent: string, conversationId: string): Promise { - await this.ensureSession(); - const res = await fetch(this.url("history", { agent, conversationId }), { headers: this.headers(true) }); + const res = await this.signedInRequest(() => fetch(this.url("history", { agent, conversationId }), { headers: this.headers(true) })); return (await this.parse<{ messages: HistoryMessage[] }>(res)).messages; } + /** + * Sends a request with the current session. A signed-in session the backend refuses (expired, + * revoked, or another user signed in) is dropped and renewed once, as chat() does. An anonymous + * visitor's 401 is final, and its stored visitor is kept. + */ + private async signedInRequest(send: () => Promise): Promise { + await this.ensureSession(); + const res = await send(); + if (res.status !== 401 || !this.session?.verified) return res; + this.forgetSession(); + await this.ensureSession(); + return send(); + } + /** Sends a message and calls `onEvent` for every streamed event. Resolves when the run ends. */ async chat( input: ChatRequestBody & { agent: string; conversationId: string }, @@ -130,6 +214,7 @@ export class ApiClient { const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader(); let buf = ""; + let renew = false; while (true) { const { done, value } = await reader.read(); if (done) break; @@ -140,8 +225,16 @@ export class ApiClient { buf = buf.slice(idx + 2); const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join("\n"); if (!data) continue; - try { onEvent(JSON.parse(data) as ChatStreamEvent); } catch { /* malformed frame */ } + let event: ChatStreamEvent; + try { event = JSON.parse(data) as ChatStreamEvent; } catch { continue; /* malformed frame */ } + if (event.type === "error" && event.code && RENEW_CODES.has(event.code)) renew = true; + try { onEvent(event); } catch { /* a failing listener never breaks the stream */ } } } + // The signed-in session ended during the run: ask /session again so the next message uses a fresh one. + if (renew && this.session?.verified) { + this.forgetSession(); + await this.ensureSession().catch(() => { /* reported on the next message */ }); + } } } diff --git a/packages/dewee-webchat/src/widget/element.ts b/packages/dewee-webchat/src/widget/element.ts index ff14e98..14075a9 100644 --- a/packages/dewee-webchat/src/widget/element.ts +++ b/packages/dewee-webchat/src/widget/element.ts @@ -1,5 +1,5 @@ // — framework-agnostic chat widget (Shadow DOM, zero dependencies). -import type { ActionSensitivity, AgentInfo, ChatRequestBody, ChatStreamEvent, HistoryMessage, PublicConfig, ToolCallView } from "../protocol"; +import { isIdentityErrorCode, isLinkErrorCode, type ActionSensitivity, type AgentInfo, type ChatRequestBody, type ChatStreamEvent, type HistoryMessage, type LinkResponse, type PublicConfig, type ToolCallView } from "../protocol"; import { ActionRegistry, runActionCall, type ActionCallStatus, type ActionConfirmHook, type PageAction, type PageActionInfo } from "../actions/action-runner"; import { formatActionResults, @@ -412,6 +412,76 @@ export class DeweeChatElement extends ElementBase { this.running?.abort(); } + /** + * Call after the visitor signs in or out on the host page: the next message uses a session for + * the current user, and the conversation is reloaded for them. + */ + async renewSession(): Promise { + this.stop(); + try { + await this.client().renewSession(); + } catch (e) { + this.showError(null, e as Error); + return; + } + if (this.activated) await this.loadConversation(); + } + + /** + * Web-first linking for a signed-in user: shows a proof code and the chat apps to send it to + * ("Continue on Telegram"). The user never types an account id. Resolves null on failure (the + * error is shown in the chat and emitted as `dewee:error`). + */ + async startLink(): Promise { + if (this.config.mode !== "inline" && !this.hasAttribute("data-open")) this.open(); + await this.activate(); + try { + const link = await this.client().link(); + this.renderLink(link); + this.emit("dewee:link", { expiresAt: link.expiresAt, deepLinks: link.deepLinks }); + return link; + } catch (e) { + this.showError(null, e as Error); + return null; + } + } + + private renderLink(link: LinkResponse) { + const s = this.strings; + const root = document.createElement("div"); + root.className = "msg assistant link"; + const bubble = document.createElement("div"); + bubble.className = "bubble"; + const md = document.createElement("div"); + md.className = "md"; + const p = (text: string) => { + const el = document.createElement("p"); + el.textContent = text; + return el; + }; + const code = document.createElement("code"); + code.textContent = link.proofCode; + const codeLine = document.createElement("p"); + codeLine.appendChild(code); + md.append(p(s.linkIntro), codeLine); + if (link.deepLinks.length) { + const list = document.createElement("ul"); + for (const t of link.deepLinks) { + const li = document.createElement("li"); + const label = t.displayName || t.name; + li.textContent = s.linkContinueOn(channelLabel(t.channelType)) + (label ? ` · ${label}` : ""); + list.appendChild(li); + } + md.appendChild(list); + } else md.appendChild(p(s.linkNoTargets)); + md.appendChild(p(s.linkExpires(Math.max(1, Math.round((link.expiresAt - Date.now()) / 60_000))))); + bubble.appendChild(md); + root.appendChild(bubble); + if (!this.messages.length) this.els.log.replaceChildren(); + this.els.log.appendChild(root); + this.scrollToBottom(true); + } + /** Starts a fresh conversation with the current agent. */ newConversation(): void { this.stop(); @@ -860,7 +930,7 @@ export class DeweeChatElement extends ElementBase { break; case "error": m.status = "error"; - this.showError(m, new ApiError(e.message, 500, e.code)); + this.showError(m, new ApiError(e.message, 500, e.code, e.remediation)); break; } this.scheduleUpdate(m); @@ -895,8 +965,12 @@ export class DeweeChatElement extends ElementBase { } private showError(m: ViewMessage | null, err: Error) { - const text = err.message || this.strings.errorGeneric; - this.emit("dewee:error", { message: text, code: (err as ApiError).code }); + const code = (err as ApiError).code; + // Identity errors get the widget's own localized copy; the server text is only a fallback. + const localized = isIdentityErrorCode(code) ? this.strings.identityErrors[code] : isLinkErrorCode(code) ? this.strings.linkErrors[code] : ""; + const text = localized || err.message || this.strings.errorGeneric; + const remediation = (err as ApiError).remediation; + this.emit("dewee:error", remediation ? { message: text, code, remediation } : { message: text, code }); if (m) { m.content = m.content ? `${m.content}\n\n> ⚠️ ${text}` : `⚠️ ${text}`; m.status = "error"; @@ -955,6 +1029,13 @@ function decorateHistory(messages: ViewMessage[]): ViewMessage[] { return messages; } +const CHANNEL_LABELS: Record = { telegram: "Telegram", discord: "Discord", zalo: "Zalo", slack: "Slack", whatsapp: "WhatsApp", messenger: "Messenger", feishu: "Feishu", line: "LINE" }; + +function channelLabel(type: string): string { + const key = type.toLowerCase().split(/[_-]/)[0]; + return CHANNEL_LABELS[key] ?? (type ? type.charAt(0).toUpperCase() + type.slice(1) : "chat"); +} + function safeJson(s: string, fallback: T): T { try { return JSON.parse(s); } catch { return fallback; } } diff --git a/packages/dewee-webchat/src/widget/i18n.ts b/packages/dewee-webchat/src/widget/i18n.ts index e5f97c0..181580d 100644 --- a/packages/dewee-webchat/src/widget/i18n.ts +++ b/packages/dewee-webchat/src/widget/i18n.ts @@ -1,3 +1,5 @@ +import type { IdentityErrorCode, LinkErrorCode } from "../protocol"; + export interface Strings { title: string; greeting: string; @@ -26,6 +28,14 @@ export interface Strings { allow: string; deny: string; actionLimit: (n: number) => string; + /** Shown for the typed identity errors of a signed-in chat. */ + identityErrors: Record; + /** Shown when starting a link fails for a reason other than an identity error. */ + linkErrors: Record; + linkIntro: string; + linkContinueOn: (channel: string) => string; + linkExpires: (minutes: number) => string; + linkNoTargets: string; } const en: Strings = { @@ -56,6 +66,24 @@ const en: Strings = { allow: "Allow", deny: "Deny", actionLimit: (n) => `Stopped after ${n} automatic action rounds. Send a message to continue.`, + identityErrors: { + IDENTITY_REQUIRED: "Please sign in to chat.", + IDENTITY_LINK_REQUIRED: "Link your chat account to continue.", + IDENTITY_CONFLICT: "This chat account is already linked to another user. Unlink it there first.", + LINK_EXPIRED: "Your session expired. Send your message again.", + LINK_REVOKED: "Your access has ended. Sign in again to continue.", + PERMISSION_DENIED: "You don't have access to this.", + SOURCE_UNAVAILABLE: "We couldn't check your sign-in right now. Please try again shortly.", + }, + linkErrors: { + idempotency_key_reused: "This link request was already used. Please try again.", + link_in_progress: "A link is already in progress. Use the code you got, or wait for it to expire.", + linking_unavailable: "Linking is not available right now. Please try again later.", + }, + linkIntro: "Send this code in a direct message from the chat account you want to link:", + linkContinueOn: (channel) => `Continue on ${channel}`, + linkExpires: (m) => `The code expires in ${m} min.`, + linkNoTargets: "No chat app is set up for linking yet.", }; const vi: Strings = { @@ -86,6 +114,24 @@ const vi: Strings = { allow: "Cho phép", deny: "Từ chối", actionLimit: (n) => `Đã dừng sau ${n} lượt thao tác tự động. Gửi tin nhắn để tiếp tục.`, + identityErrors: { + IDENTITY_REQUIRED: "Bạn đăng nhập để trò chuyện nhé.", + IDENTITY_LINK_REQUIRED: "Bạn cần liên kết tài khoản chat để tiếp tục.", + IDENTITY_CONFLICT: "Tài khoản chat này đã liên kết với người dùng khác. Hãy hủy liên kết ở đó trước.", + LINK_EXPIRED: "Phiên đăng nhập đã hết hạn. Bạn gửi lại tin nhắn giúp mình nhé.", + LINK_REVOKED: "Quyền truy cập của bạn đã kết thúc. Hãy đăng nhập lại để tiếp tục.", + PERMISSION_DENIED: "Bạn không có quyền thực hiện việc này.", + SOURCE_UNAVAILABLE: "Hiện chưa kiểm tra được đăng nhập của bạn. Bạn thử lại sau ít phút nhé.", + }, + linkErrors: { + idempotency_key_reused: "Yêu cầu liên kết này đã được dùng. Bạn thử lại nhé.", + link_in_progress: "Đang có một liên kết chưa hoàn tất. Hãy dùng mã đã nhận hoặc chờ mã hết hạn.", + linking_unavailable: "Hiện chưa thể liên kết. Bạn thử lại sau nhé.", + }, + linkIntro: "Gửi mã này trong tin nhắn riêng từ tài khoản chat bạn muốn liên kết:", + linkContinueOn: (channel) => `Tiếp tục trên ${channel}`, + linkExpires: (m) => `Mã hết hạn sau ${m} phút.`, + linkNoTargets: "Chưa có ứng dụng chat nào được thiết lập để liên kết.", }; export const locales: Record = { en, vi }; diff --git a/packages/dewee-webchat/test/identity.test.ts b/packages/dewee-webchat/test/identity.test.ts new file mode 100644 index 0000000..b31ed46 --- /dev/null +++ b/packages/dewee-webchat/test/identity.test.ts @@ -0,0 +1,681 @@ +// Signed-in users through a Dewee identity session exchange, against a fake gateway that serves the +// identity HTTP routes and the WS v3 protocol on one port. Covers isolation between users, fail-closed +// session issuance, typed error forwarding, session reuse and rate limits, web-first linking and +// secret hygiene. +import { createHmac, generateKeyPairSync, randomUUID, verify as verifySignature, type KeyObject } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { WebSocketServer } from "ws"; +import type { ChatStreamEvent } from "../src/protocol"; +import { + createDeweeHandler, + signVisitorToken, + verifyVisitorToken, + type DeweeHandlerOptions, + type DeweeIdentityOptions, + type RateLimitBucket, +} from "../src/server"; +import { deweeIdentitySdk } from "../src/server/identity"; +import { constantTimeEqual } from "../src/server/security"; +import { ApiClient } from "../src/widget/api-client"; +import { pickStrings } from "../src/widget/i18n"; + +const GW_TOKEN = "gw-token-NEVER-IN-BROWSER-0001"; +const ASSERTION_SECRET = "assertion-secret-NEVER-IN-BROWSER-0000000001"; +const VISITOR_SECRET = "visitor-secret-NEVER-IN-BROWSER-00000000001"; +const APP_ID = "8f0c6a2e-3b1d-4c5e-9a7f-1b2c3d4e5f60"; +const ISSUER = "https://host.example"; + +const ec = generateKeyPairSync("ec", { namedCurve: "P-256" }); +const privateJwk = { ...(ec.privateKey.export({ format: "jwk" }) as Record), kid: "k1", alg: "ES256" } as { kty: string; kid: string; d: string; alg: string }; +const ecPublic: KeyObject = ec.publicKey; + +// ---------- fake gateway ---------- + +interface Session { principal: string; agent: string; expiresAt: number } +const sessions = new Map(); +const expiredSessions = new Set(); +const revokeDuringRun = new Set(); +const seen = { connects: [] as any[], sends: [] as any[], exchanges: [] as { sub: string; agent: string; ttl?: number; token?: string }[] }; +/** Simulates a gateway (or replica) that has identity switched off: it ignores `identity_session`. */ +const gw = { ignoreIdentity: false, linkDisabled: false }; +let gatewayUrl = ""; +let server: Server; + +const b64json = (s: string) => JSON.parse(Buffer.from(s, "base64url").toString("utf8")); + +/** Checks a partner assertion the way the gateway does (HS256 with the secret, or ES256 with the JWKS key). */ +function verifyAssertion(jws: string): { sub: string } | null { + const [h, p, s] = jws.split("."); + if (!h || !p || !s) return null; + const header = b64json(h); + const claims = b64json(p); + let ok = false; + if (header.alg === "HS256") ok = createHmac("sha256", ASSERTION_SECRET).update(`${h}.${p}`).digest("base64url") === s; + if (header.alg === "ES256") ok = verifySignature("sha256", Buffer.from(`${h}.${p}`), { key: ecPublic, dsaEncoding: "ieee-p1363" }, Buffer.from(s, "base64url")); + if (!ok || header.typ !== "dewee-session+jwt" || claims.aud !== `dewee:${APP_ID}` || claims.iss !== ISSUER) return null; + return { sub: claims.sub }; +} + +const identityError = (code: string, action: string, audience = "end_user") => ({ error: { code, message: code, type: "identity_error", remediation: { action, audience } } }); + +function sendJson(res: ServerResponse, status: number, body: unknown) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +async function onHttp(req: IncomingMessage, res: ServerResponse) { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {}; + if (req.method === "POST" && req.url === "/v1/identity/sessions/exchange") { + const auth = String(req.headers.authorization ?? ""); + const proof = auth.startsWith("Dewee-Assertion ") ? verifyAssertion(auth.slice(16)) : null; + if (!proof) return sendJson(res, 401, identityError("IDENTITY_REQUIRED", "reauthenticate")); + const record: (typeof seen.exchanges)[number] = { sub: proof.sub, agent: body.agent_id, ttl: body.ttl_seconds }; + seen.exchanges.push(record); + if (proof.sub === "suspended-user") return sendJson(res, 409, identityError("LINK_REVOKED", "reauthenticate")); + if (proof.sub === "operator-problem") return sendJson(res, 403, identityError("PERMISSION_DENIED", "contact_admin", "operator")); + const token = `idsess_${randomUUID()}`; + record.token = token; + const expiresAt = Date.now() + (body.ttl_seconds ?? 3600) * 1000; + // One principal per subject: a stable fake id derived from it. + const principal = createHmac("sha256", "principal").update(proof.sub).digest("hex").slice(0, 32); + sessions.set(token, { principal, agent: body.agent_id, expiresAt }); + return sendJson(res, 201, { + session_token: token, token_type: "identity_session", header: "X-Dewee-Identity-Session", session_id: randomUUID(), + principal_id: principal, application_id: APP_ID, agent_id: randomUUID(), expires_at: new Date(expiresAt).toISOString(), test_mode: false, + }); + } + if (req.method === "POST" && req.url === "/v1/identity/links") { + if (gw.linkDisabled) return sendJson(res, 404, { error: { code: "NOT_FOUND", message: "route not found" } }); + const s = sessions.get(String(req.headers["x-dewee-identity-session"] ?? "")); + if (!s) return sendJson(res, 401, identityError("IDENTITY_REQUIRED", "reauthenticate")); + const key = req.headers["idempotency-key"]; + if (key === "reused-key") return sendJson(res, 409, { error: { code: "IDEMPOTENCY_KEY_REUSED", message: "idempotency key reused" } }); + if (key === "busy-key") return sendJson(res, 409, { error: { code: "FAILED_PRECONDITION", message: "link already in progress" } }); + return sendJson(res, 201, { + transaction_id: randomUUID(), kind: "web_first", state: "awaiting_channel_proof", proof_code: "482913", + expires_at: new Date(Date.now() + 600_000).toISOString(), created: true, + deep_links: [{ channel_type: "telegram", channel_instance_id: randomUUID(), name: "acme-bot", display_name: "Acme Bot" }], + }); + } + sendJson(res, 404, { error: { code: "NOT_FOUND", message: "not found" } }); +} + +beforeAll(async () => { + server = createServer((req, res) => void onHttp(req, res)); + const wss = new WebSocketServer({ server, path: "/ws" }); + wss.on("connection", (ws) => { + let user = ""; + let sessionToken = ""; + ws.on("message", (raw) => { + const f = JSON.parse(String(raw)); + const ok = (payload: unknown) => ws.send(JSON.stringify({ type: "res", id: f.id, ok: true, payload })); + const err = (code: string, action?: string) => + ws.send(JSON.stringify({ type: "res", id: f.id, ok: false, error: { code, message: `${code} (gateway)`, ...(action ? { details: { remediation: { action, audience: "end_user" } } } : {}) } })); + if (f.method === "connect") { + seen.connects.push(f.params); + if (f.params.token !== GW_TOKEN) return err("UNAUTHORIZED"); + if (f.params.identity_session !== undefined && !gw.ignoreIdentity) { + const s = sessions.get(f.params.identity_session); + if (expiredSessions.has(f.params.identity_session)) return err("LINK_EXPIRED", "reauthenticate"); + if (!s) return err("IDENTITY_REQUIRED", "reauthenticate"); + if (f.params.user_id !== undefined && f.params.user_id !== `tuc:${s.principal}`) return err("IDENTITY_CONFLICT", "contact_admin"); + user = `tuc:${s.principal}`; + sessionToken = f.params.identity_session; + } else user = f.params.user_id ?? ""; // identity switched off: the operator token's own (empty) user + return ok({ role: "operator", user_id: user }); + } + if (f.method === "chat.send") { + seen.sends.push({ ...f.params, user }); + if (sessionToken && sessions.get(sessionToken)?.agent !== f.params.agentId) return err("PERMISSION_DENIED", "contact_admin"); + const base = { agentId: f.params.agentId, runId: "run-1", sessionKey: f.params.sessionKey }; + ws.send(JSON.stringify({ type: "event", event: "agent", payload: { ...base, type: "run.started", payload: {} } })); + if (revokeDuringRun.has(sessionToken)) return err("LINK_REVOKED", "reauthenticate"); + ws.send(JSON.stringify({ type: "event", event: "agent", payload: { ...base, type: "chunk", payload: { content: `hello ${user}` } } })); + return ok({ content: `hello ${user}` }); + } + if (f.method === "chat.history") return ok({ messages: [{ role: "user", content: `history of ${user}` }] }); + ok({}); + }); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + gatewayUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(() => new Promise((r) => server.close(() => r()))); + +// ---------- handler under test ---------- + +/** Every body this file's handlers sent to a browser; checked for secrets at the end. */ +const browserBodies: string[] = []; + +const cookieUser = async (req: Request) => { + const uid = /(?:^|;\s*)uid=([^;]+)/.exec(req.headers.get("cookie") ?? "")?.[1]; + if (uid === "boom") throw new Error("login store down"); + return uid ? { subject: uid } : null; +}; + +const baseIdentity = { sdk: deweeIdentitySdk, authenticate: cookieUser, appId: APP_ID, issuer: ISSUER, assertionSecret: ASSERTION_SECRET }; + +function makeHandler(identity: Partial = {}, extra: Partial = {}) { + const h = createDeweeHandler({ + gatewayUrl, + token: GW_TOKEN, + secret: VISITOR_SECRET, + agents: ["support", "sales"], + rateLimit: false, + identity: { ...baseIdentity, ...identity }, + ...extra, + }); + return async (req: Request) => { + const res = await h(req); + const text = await res.text(); + browserBodies.push(text, JSON.stringify(Object.fromEntries(res.headers))); + return { status: res.status, text, json: () => JSON.parse(text) }; + }; +} +type TestHandler = ReturnType; + +const BASE = "https://site.test/api/dewee"; +const session = (h: TestHandler, uid?: string, body: unknown = {}) => + h(new Request(`${BASE}/session`, { method: "POST", headers: uid ? { cookie: `uid=${uid}` } : {}, body: JSON.stringify(body) })); +const chat = (h: TestHandler, token: string, body: Record = {}, cookie?: string) => + h(new Request(`${BASE}/chat`, { method: "POST", headers: { authorization: `Bearer ${token}`, ...(cookie ? { cookie } : {}) }, body: JSON.stringify({ message: "hi", conversationId: "conv_ident1", ...body }) })); +const history = (h: TestHandler, token: string, cookie?: string) => + h(new Request(`${BASE}/history?agent=support&conversationId=conv_ident1`, { headers: { authorization: `Bearer ${token}`, ...(cookie ? { cookie } : {}) } })); +const link = (h: TestHandler, token: string, body: unknown = {}, cookie?: string) => + h(new Request(`${BASE}/link`, { method: "POST", headers: { authorization: `Bearer ${token}`, ...(cookie ? { cookie } : {}) }, body: JSON.stringify(body) })); +const sse = (text: string): ChatStreamEvent[] => text.split("\n\n").filter((f) => f.startsWith("data:")).map((f) => JSON.parse(f.slice(5))); +const claimsOf = (token: string) => b64json(token.split(".")[0]); +/** The raw identity session inside a visitor token, as only the server can read it. */ +const rawIds = async (token: string) => (await verifyVisitorToken(VISITOR_SECRET, token))?.ids; +const quiet = async (fn: () => Promise): Promise => { + const spies = [vi.spyOn(console, "warn").mockImplementation(() => {}), vi.spyOn(console, "error").mockImplementation(() => {})]; + try { + return await fn(); + } finally { + for (const s of spies) s.mockRestore(); + } +}; + +describe("identity sessions: isolation", () => { + it("gives users A and B different identity sessions and never sends a client-chosen user_id", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const b = (await session(h, "user-b")).json(); + expect(a.verified).toBe(true); + expect(b.verified).toBe(true); + const [idsA, idsB] = [await rawIds(a.token), await rawIds(b.token)]; + expect(idsA).toMatch(/^idsess_/); + expect(idsA).not.toBe(idsB); + expect(a.visitorId).not.toBe(b.visitorId); + + const ra = sse((await chat(h, a.token, { agent: "support", userId: "evil" })).text); + const connA = seen.connects.at(-1); + expect(connA).toEqual({ token: GW_TOKEN, identity_session: idsA }); + expect(connA).not.toHaveProperty("user_id"); + const rb = sse((await chat(h, b.token)).text); + expect(seen.connects.at(-1).identity_session).toBe(idsB); + expect(ra.at(-1)).toMatchObject({ type: "done" }); + expect((ra.at(-1) as any).content).not.toBe((rb.at(-1) as any).content); + }); + + it("keeps chatting as A when A's visitor token is replayed with B's cookie (no switch)", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const asA = sse((await chat(h, a.token)).text).at(-1) as any; + const replay = sse((await chat(h, a.token, {}, "uid=user-b")).text).at(-1) as any; + expect(seen.connects.at(-1).identity_session).toBe(await rawIds(a.token)); + expect(replay.content).toBe(asA.content); + }); + + it("keeps the same visitor id for the same user, so history follows them", async () => { + const h = makeHandler(); + const first = (await session(h, "user-a")).json(); + const second = (await session(h, "user-a")).json(); + expect(second.visitorId).toBe(first.visitorId); + }); + + it("answers 401 to a tampered or foreign-signed token", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const b = (await session(h, "user-b")).json(); + const [, sig] = a.token.split("."); + const swapped = Buffer.from(JSON.stringify({ ...claimsOf(a.token), ids: claimsOf(b.token).ids })).toString("base64url"); + expect((await chat(h, `${swapped}.${sig}`)).status).toBe(401); + const forged = await signVisitorToken("another-secret-of-at-least-32-chars!!", { v: "visitor_123", exp: Date.now() + 60_000, ids: (await rawIds(b.token))!, ida: "support" }); + expect((await chat(h, forged)).status).toBe(401); + }); + + it("binds the session to one agent that must still be allowed", async () => { + const h = makeHandler(); + const cfg = (await h(new Request(`${BASE}/config`))).json(); + expect(cfg).toMatchObject({ identity: true, allowAgentSwitch: false, defaultAgent: "support" }); + const a = (await session(h, "user-a")).json(); + expect(seen.exchanges.at(-1)).toMatchObject({ sub: "user-a", agent: "support" }); + expect((await chat(h, a.token, { agent: "sales" })).status).toBe(403); + // The same secret after "support" left the allowlist: the old token is refused for chat and history. + const narrowed = makeHandler({ agent: "sales" }, { agents: ["sales"] }); + const r = await chat(narrowed, a.token); + expect([r.status, r.json().error.code]).toEqual([403, "agent_not_allowed"]); + expect((await history(narrowed, a.token)).status).toBe(403); + }); +}); + +describe("identity sessions: sealed token", () => { + it("never puts the raw identity session or the subject in the browser token", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const raw = (await rawIds(a.token))!; + const decoded = Buffer.from(a.token.split(".")[0], "base64url").toString("utf8"); + expect(decoded).not.toContain(raw); + expect(decoded).not.toContain("user-a"); + expect(Object.keys(claimsOf(a.token)).sort()).toEqual(["exp", "ida", "ids", "v"]); + // Sealed for this visitor and agent: moving the ciphertext into another visitor's claims breaks it. + const other = await signVisitorToken(VISITOR_SECRET, { v: "visitor_other1", exp: Date.now() + 60_000 }); + const moved = { ...claimsOf(other), ids: claimsOf(a.token).ids, ida: "support" }; + const body = Buffer.from(JSON.stringify(moved)).toString("base64url"); + const sig = createHmac("sha256", VISITOR_SECRET).update(body).digest("base64url"); + expect(await verifyVisitorToken(VISITOR_SECRET, `${body}.${sig}`)).toBeNull(); + }); +}); + +describe("identity sessions: sealed token limits", () => { + it("seals and opens a session token of the SDK's 512-character maximum, with a subject digest", async () => { + for (const ids of ["a".repeat(512), '"\\'.repeat(256), "\u0001".repeat(512), "é".repeat(512)]) { + const token = await signVisitorToken(VISITOR_SECRET, { v: "visitor_max512", exp: Date.now() + 60_000, ids, ida: "support", sub: "d".repeat(43) }); + expect(await verifyVisitorToken(VISITOR_SECRET, token)).toEqual({ v: "visitor_max512", exp: expect.any(Number), ids, ida: "support", sub: "d".repeat(43) }); + } + const tooLong = await signVisitorToken(VISITOR_SECRET, { v: "visitor_max512", exp: Date.now() + 60_000, ids: "a".repeat(513), ida: "support" }); + expect(await verifyVisitorToken(VISITOR_SECRET, tooLong)).toBeNull(); + }); + + it("compares strings in constant time without changing the answer", () => { + expect(constantTimeEqual("abc", "abc")).toBe(true); + expect(constantTimeEqual("abc", "abd")).toBe(false); + expect(constantTimeEqual("abc", "abcd")).toBe(false); + expect(constantTimeEqual("", "")).toBe(true); + }); +}); + +describe("identity sessions: fail closed", () => { + it("refuses identity mode combined with client credentials and other misconfiguration", () => { + const base = { gatewayUrl: "https://gw.example.com", token: GW_TOKEN, secret: VISITOR_SECRET, agents: ["support"] }; + const identity = baseIdentity; + expect(() => createDeweeHandler({ ...base, identity, allowClientCredentials: true })).toThrow(/allowClientCredentials/); + expect(() => createDeweeHandler({ ...base, identity, allowClientCredentials: { allowedHosts: ["gw.example.com"] } })).toThrow(/allowClientCredentials/); + expect(() => createDeweeHandler({ ...base, token: undefined, identity })).toThrow(/gatewayUrl and token/); + expect(() => createDeweeHandler({ ...base, identity: { ...identity, sdk: undefined as never } })).toThrow(/dewee-webchat\/server\/identity/); + expect(() => createDeweeHandler({ ...base, identity: { ...identity, privateKeyJwk: privateJwk } })).toThrow(/exactly one/); + expect(() => createDeweeHandler({ ...base, identity: { ...identity, audience: "dewee:someone-else" } })).toThrow(/audience/); + expect(() => createDeweeHandler({ ...base, identity: { ...identity, appId: "acme" } })).toThrow(/appId/); + expect(() => createDeweeHandler({ ...base, identity: { ...identity, agent: "admin" } })).toThrow(/not in agents/); + expect(() => createDeweeHandler({ ...base, identity: { ...identity, assertionSecret: "short" } })).toThrow(/too short/); + // The error never echoes a secret. + try { createDeweeHandler({ ...base, identity, allowClientCredentials: true }); } catch (e) { expect(String(e)).not.toContain(ASSERTION_SECRET); } + }); + + it("falls back to an anonymous visitor only for a visitor who is not signed in", async () => { + const h = makeHandler(); + const s = (await session(h)).json(); + expect(s.verified).toBeUndefined(); + expect(s).not.toHaveProperty("expiresIn"); + expect(claimsOf(s.token)).not.toHaveProperty("ids"); + await chat(h, s.token); + expect(seen.connects.at(-1)).toEqual({ token: GW_TOKEN, user_id: `webchat-${s.visitorId}` }); + }); + + it("answers 503 and issues nothing when authenticate throws, required or not", async () => { + for (const required of [false, true]) { + const r = await quiet(() => session(makeHandler({ required }), "boom")); + expect(r.status).toBe(503); + expect(r.json()).toEqual({ error: { message: expect.any(String), code: "SOURCE_UNAVAILABLE", remediation: { action: "retry_later", audience: "end_user" } } }); + } + }); + + it("issues no session at all when identity is required and the user is not signed in", async () => { + const h = makeHandler({ required: true }); + const none = await session(h); + expect(none.status).toBe(401); + expect(none.json()).toEqual({ error: { message: expect.any(String), code: "IDENTITY_REQUIRED", remediation: { action: "reauthenticate", audience: "end_user" } } }); + // An anonymous visitor token from before the switch is refused too. + const anon = await signVisitorToken(VISITOR_SECRET, { v: "visitor_123", exp: Date.now() + 60_000 }); + const r = await chat(h, anon); + expect([r.status, r.json().error.code]).toEqual([401, "IDENTITY_REQUIRED"]); + }); + + it("never downgrades a signed-in user whose exchange is refused", async () => { + const h = makeHandler(); + const [r, bad, email, operator] = await quiet(async () => [ + await session(h, "suspended-user"), + await makeHandler({ assertionSecret: "wrong-secret-but-long-enough-0000000000001" })( + new Request(`${BASE}/session`, { method: "POST", headers: { cookie: "uid=user-a" }, body: "{}" }), + ), + await session(h, "someone@example.com"), + await session(h, "operator-problem"), + ]); + expect(r.status).toBe(401); + expect(r.json()).toEqual({ error: { message: expect.any(String), code: "LINK_REVOKED", remediation: { action: "reauthenticate", audience: "end_user" } } }); + expect([bad.status, bad.json().error.code]).toEqual([401, "IDENTITY_REQUIRED"]); + expect([email.status, email.json().error.code]).toEqual([500, "identity_misconfigured"]); + // A refusal addressed to the operator is this server's misconfiguration, not the user's problem. + expect(operator.json()).toEqual({ error: { message: expect.any(String), code: "identity_misconfigured" } }); + expect(operator.status).toBe(500); + for (const res of [r, bad, email, operator]) expect(res.json()).not.toHaveProperty("token"); + }); + + it("refuses to chat when the gateway ignores the identity session (identity switched off)", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const sends = seen.sends.length; + gw.ignoreIdentity = true; + try { + const r = await quiet(() => chat(h, a.token)); + expect(r.status).toBe(503); + expect(r.json().error.code).toBe("SOURCE_UNAVAILABLE"); + const hist = await quiet(() => history(h, a.token)); + expect([hist.status, hist.json().error.code]).toEqual([503, "SOURCE_UNAVAILABLE"]); + expect(seen.sends.length).toBe(sends); // nothing ran as the operator's user + } finally { + gw.ignoreIdentity = false; + } + }); + + it("caps the visitor token at the identity session lifetime, measured from receipt", async () => { + const short = (await session(makeHandler({ sessionTtlSeconds: 120 }), "user-a")).json(); + expect(seen.exchanges.at(-1)?.ttl).toBe(120); + expect(short.expiresAt).toBeLessThanOrEqual(Date.now() + 120_000); + expect(short.expiresAt).toBeGreaterThan(Date.now() + 100_000); + expect(short.expiresIn).toBeGreaterThan(100_000); + expect(short.expiresIn).toBeLessThanOrEqual(120_000); + expect(claimsOf(short.token).exp).toBe(short.expiresAt); + const visitorCap = (await session(makeHandler({}, { sessionTtlMs: 30_000 }), "user-a")).json(); + expect(visitorCap.expiresAt).toBeLessThanOrEqual(Date.now() + 30_000); + }); + + it("anonymous handlers ignore identity tokens and expose no link route", async () => { + const h = createDeweeHandler({ gatewayUrl, token: GW_TOKEN, secret: VISITOR_SECRET, agents: ["support"], rateLimit: false }); + const withIds = await signVisitorToken(VISITOR_SECRET, { v: "visitor_123", exp: Date.now() + 60_000, ids: "idsess_x", ida: "support" }); + const r = await h(new Request(`${BASE}/chat`, { method: "POST", headers: { authorization: `Bearer ${withIds}` }, body: JSON.stringify({ message: "hi" }) })); + expect(r.status).toBe(401); + expect((await h(new Request(`${BASE}/link`, { method: "POST", body: "{}" }))).status).toBe(404); + const cfg = await (await h(new Request(`${BASE}/config`))).json(); + expect(cfg).not.toHaveProperty("identity"); + }); +}); + +describe("identity sessions: rebinding each request", () => { + it("refuses A's token under B's cookie or no cookie, and accepts it under A's cookie", async () => { + const h = makeHandler({ rebindEachRequest: true }); + const a = (await session(h, "user-a")).json(); + const asA = await chat(h, a.token, {}, "uid=user-a"); + expect(asA.status).toBe(200); + expect(sse(asA.text).at(-1)).toMatchObject({ type: "done" }); + expect((await history(h, a.token, "uid=user-a")).status).toBe(200); + for (const cookie of ["uid=user-b", undefined]) { + const r = await chat(h, a.token, {}, cookie); + expect(r.json()).toEqual({ error: { message: expect.any(String), code: "IDENTITY_REQUIRED", remediation: { action: "reauthenticate", audience: "end_user" } } }); + expect(r.status).toBe(401); + expect((await history(h, a.token, cookie)).status).toBe(401); + expect((await link(h, a.token, {}, cookie)).status).toBe(401); + } + }); + + it("answers 503 when authenticate throws", async () => { + const h = makeHandler({ rebindEachRequest: true }); + const a = (await session(h, "user-a")).json(); + const r = await quiet(() => chat(h, a.token, {}, "uid=boom")); + expect([r.status, r.json().error.code]).toEqual([503, "SOURCE_UNAVAILABLE"]); + }); + + it("is off by default (see the isolation tests) and ignores anonymous visitors", async () => { + const h = makeHandler({ rebindEachRequest: true }); + const anon = (await session(h)).json(); + expect((await chat(h, anon.token, {}, "uid=user-b")).status).toBe(200); + }); +}); + +describe("identity sessions: reuse and rate limits", () => { + it("reuses one identity session per user until it nears expiry, and drops it once the gateway says it is dead", async () => { + const h = makeHandler(); + const before = seen.exchanges.length; + const first = (await session(h, "user-a")).json(); + const second = (await session(h, "user-a")).json(); + expect(seen.exchanges.length).toBe(before + 1); + expect(await rawIds(second.token)).toBe(await rawIds(first.token)); + expiredSessions.add((await rawIds(first.token))!); + expect((await chat(h, first.token)).status).toBe(401); + const third = (await session(h, "user-a")).json(); + expect(seen.exchanges.length).toBe(before + 2); + expect(await rawIds(third.token)).not.toBe(await rawIds(first.token)); + }); + + it("re-exchanges a short session only near its end (min(60s, ttl/2))", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const h = makeHandler({ sessionTtlSeconds: 60 }); + const before = seen.exchanges.length; + await session(h, "user-a"); + vi.setSystemTime(Date.now() + 20_000); + await session(h, "user-a"); + expect(seen.exchanges.length).toBe(before + 1); + vi.setSystemTime(Date.now() + 15_000); // 35 s in: inside the last 30 s + await session(h, "user-a"); + expect(seen.exchanges.length).toBe(before + 2); + } finally { + vi.useRealTimers(); + } + }); + + it("limits signed-in sessions per user, links per user, and returns a valid anonymous token without minting", async () => { + const calls: Array<[string, RateLimitBucket]> = []; + const h = makeHandler({}, { rateLimit: (key, bucket) => (calls.push([key, bucket]), true) }); + const a = (await session(h, "user-a")).json(); + expect(calls).toEqual([["unknown", "preauth"], ["subject:user-a", "verified"]]); + calls.length = 0; + expect((await link(h, a.token)).status).toBe(200); + expect(calls).toEqual([[`visitor:${a.visitorId}`, "link"]]); + calls.length = 0; + const anon = (await session(h)).json(); + expect(calls.map(([, b]) => b)).toEqual(["preauth", "session"]); + calls.length = 0; + const again = (await session(h, undefined, { token: anon.token })).json(); + expect(again).toEqual({ visitorId: anon.visitorId, token: anon.token, expiresAt: anon.expiresAt }); + expect(calls).toEqual([["unknown", "preauth"]]); + }); + + it("caps authenticate calls per IP before running it, on /session and on rebinding", async () => { + let authCalls = 0; + const counted = async (req: Request) => (authCalls++, cookieUser(req)); + const h = makeHandler({ authenticate: counted, rebindEachRequest: true }, { rateLimit: undefined }); + const a = (await session(h, "user-a")).json(); + for (let i = 0; i < 118; i++) await session(h, undefined, { token: "x" }); + expect((await chat(h, a.token, {}, "uid=user-a")).status).toBe(200); // the 120th call + const before = authCalls; + const r = await chat(h, a.token, {}, "uid=user-a"); + expect([r.status, r.json().error.code]).toEqual([429, "rate_limited"]); + expect((await session(h, "user-a")).status).toBe(429); + expect(authCalls).toBe(before); // refused before authenticate ran + }); + + it("answers 429 once a user's signed-in session budget is spent", async () => { + const h = makeHandler({}, { rateLimit: undefined }); + let last = 0; + for (let i = 0; i < 31; i++) last = (await session(h, "busy-user")).status; + expect(last).toBe(429); + expect((await session(h, "user-b")).status).toBe(200); + }); +}); + +describe("identity sessions: typed gateway errors", () => { + it("forwards LINK_EXPIRED at connect as a typed 401 for chat and history", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + expiredSessions.add((await rawIds(a.token))!); + const r = await chat(h, a.token); + expect(r.status).toBe(401); + expect(r.json()).toEqual({ error: { message: expect.any(String), code: "LINK_EXPIRED", remediation: { action: "reauthenticate", audience: "end_user" } } }); + const hist = await history(h, a.token); + expect([hist.status, hist.json().error.code]).toEqual([401, "LINK_EXPIRED"]); + }); + + it("forwards an identity error raised during the run in the event stream", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + revokeDuringRun.add((await rawIds(a.token))!); + const events = sse((await chat(h, a.token)).text); + expect(events.at(-1)).toEqual({ type: "error", message: expect.any(String), code: "LINK_REVOKED", remediation: { action: "reauthenticate", audience: "end_user" } }); + }); + + it("reads history as the session's user", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const r = await history(h, a.token); + expect(r.json().messages[0].content).toMatch(/^history of tuc:/); + expect(seen.connects.at(-1)).not.toHaveProperty("user_id"); + }); +}); + +describe("web-first linking", () => { + it("returns the proof code and the chat accounts to send it to", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const r = await link(h, a.token, { idempotencyKey: "req-1" }); + expect(r.status).toBe(200); + const body = r.json(); + expect(body).toMatchObject({ proofCode: "482913", deepLinks: [{ channelType: "telegram", name: "acme-bot", displayName: "Acme Bot" }] }); + expect(body.expiresAt).toBeGreaterThan(Date.now()); + }); + + it("refuses visitors who are not signed in", async () => { + const h = makeHandler(); + const anon = (await session(h)).json(); + const r = await link(h, anon.token); + expect([r.status, r.json().error.code]).toEqual([401, "IDENTITY_REQUIRED"]); + }); + + it("maps the gateway's link conflicts and a switched-off gateway to their own codes", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const reused = await quiet(() => link(h, a.token, { idempotencyKey: "reused-key" })); + expect([reused.status, reused.json().error.code]).toEqual([409, "idempotency_key_reused"]); + const busy = await quiet(() => link(h, a.token, { idempotencyKey: "busy-key" })); + expect([busy.status, busy.json().error.code]).toEqual([409, "link_in_progress"]); + gw.linkDisabled = true; + try { + const off = await quiet(() => link(h, a.token)); + expect([off.status, off.json().error.code]).toEqual([503, "linking_unavailable"]); + } finally { + gw.linkDisabled = false; + } + }); +}); + +describe("jwks applications", () => { + it("signs with a private JWK", async () => { + const h = makeHandler({ assertionSecret: undefined, privateKeyJwk: privateJwk }); + const s = await session(h, "user-c"); + expect(s.status).toBe(200); + expect(s.json().verified).toBe(true); + }); +}); + +describe("widget client", () => { + const json = (status: number, v: unknown) => new Response(JSON.stringify(v), { status, headers: { "content-type": "application/json" } }); + const config = { agents: [{ id: "support" }], defaultAgent: "support", allowAgentSwitch: false, allowClientCredentials: false, maxMessageLength: 4000, version: "t", identity: true }; + const done = () => new Response(`data: ${JSON.stringify({ type: "done", content: "ok" })}\n\n`, { headers: { "content-type": "text/event-stream" } }); + + /** Runs `fn` with a fake backend; `route` answers each call by path. Returns the paths called. */ + async function withBackend(route: (path: string, n: number, init?: RequestInit) => Response, fn: (api: ApiClient) => Promise, storage = new Map()) { + vi.stubGlobal("localStorage", { getItem: (k: string) => storage.get(k) ?? null, setItem: (k: string, v: string) => void storage.set(k, v), removeItem: (k: string) => void storage.delete(k) }); + const calls: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => { + const path = new URL(url, "https://site.test").pathname.split("/").pop()!; + calls.push(path); + if (path === "config") return json(200, config); + return route(path, calls.filter((c) => c === path).length, init); + })); + try { + await fn(new ApiClient({ endpoint: "/api/dewee" })); + } finally { + vi.unstubAllGlobals(); + } + return calls; + } + + const signedIn = (n: number, extra: Record = {}) => json(200, { visitorId: "v_signed_in1", token: `tok${n}`, expiresAt: Date.now() + 3_600_000, verified: true, ...extra }); + + it("re-requests /session on LINK_EXPIRED and keeps signed-in sessions out of storage", async () => { + const storage = new Map(); + const events: ChatStreamEvent[] = []; + const calls = await withBackend((path, n, init) => { + if (path === "session") return signedIn(n); + if (path === "chat" && n === 1) return json(401, { error: { message: "expired", code: "LINK_EXPIRED", remediation: { action: "reauthenticate", audience: "end_user" } } }); + expect((init?.headers as Record).authorization).toBe("Bearer tok2"); + return done(); + }, (api) => api.chat({ message: "hi", agent: "support", conversationId: "conv_ident1" }, (e) => events.push(e)), storage); + expect(calls).toEqual(["config", "session", "chat", "session", "chat"]); + expect(events).toEqual([{ type: "done", content: "ok" }]); + expect([...storage.keys()]).toEqual([]); + }); + + it("renews the session after a LINK_REVOKED event in the stream", async () => { + const calls = await withBackend((path, n) => { + if (path === "session") return signedIn(n); + return new Response(`data: ${JSON.stringify({ type: "error", message: "revoked", code: "LINK_REVOKED" })}\n\n`, { headers: { "content-type": "text/event-stream" } }); + }, async (api) => { + await api.chat({ message: "hi", agent: "support", conversationId: "conv_ident1" }, () => {}); + expect((await api.ensureSession()).token).toBe("tok2"); + }); + expect(calls).toEqual(["config", "session", "chat", "session"]); + }); + + it("renews an expired or revoked session once when starting a link", async () => { + const calls = await withBackend((path, n, init) => { + if (path === "session") return signedIn(n); + if (n === 1) return json(401, { error: { message: "revoked", code: "LINK_REVOKED" } }); + expect((init?.headers as Record).authorization).toBe("Bearer tok2"); + return json(200, { proofCode: "482913", expiresAt: Date.now() + 600_000, deepLinks: [] }); + }, async (api) => { + expect((await api.link()).proofCode).toBe("482913"); + }); + expect(calls).toEqual(["config", "session", "link", "session", "link"]); + }); + + it("refreshes a signed-in session from expiresIn, counted from receipt, not from a skewed expiresAt", async () => { + const calls = await withBackend((path, n) => { + // The browser clock is ahead: expiresAt already looks past, but the session has 90 s left. + if (path === "session") return signedIn(n, { expiresAt: Date.now() - 5_000, expiresIn: 90_000 }); + return done(); + }, async (api) => { + await api.chat({ message: "1", agent: "support", conversationId: "conv_ident1" }, () => {}); + await api.chat({ message: "2", agent: "support", conversationId: "conv_ident1" }, () => {}); + }); + expect(calls.filter((c) => c === "session")).toHaveLength(1); + }); + + it("has localized copy for every identity and link error", () => { + for (const locale of ["en", "vi"]) { + const s = pickStrings(locale); + for (const code of ["IDENTITY_REQUIRED", "IDENTITY_LINK_REQUIRED", "IDENTITY_CONFLICT", "LINK_EXPIRED", "LINK_REVOKED", "PERMISSION_DENIED", "SOURCE_UNAVAILABLE"] as const) { + expect(s.identityErrors[code]).toBeTruthy(); + } + for (const code of ["idempotency_key_reused", "link_in_progress", "linking_unavailable"] as const) expect(s.linkErrors[code]).toBeTruthy(); + } + expect(pickStrings("vi").identityErrors.LINK_EXPIRED).not.toBe(pickStrings("en").identityErrors.LINK_EXPIRED); + }); +}); + +describe("secret hygiene", () => { + it("never sends the gateway token, assertion secret, visitor secret, private key or a raw identity session to a browser", () => { + expect(browserBodies.length).toBeGreaterThan(40); + const all = browserBodies.join("\n"); + for (const secret of [GW_TOKEN, ASSERTION_SECRET, VISITOR_SECRET, privateJwk.d]) expect(all).not.toContain(secret); + const issued = seen.exchanges.map((e) => e.token).filter((t): t is string => !!t); + expect(issued.length).toBeGreaterThan(10); + for (const token of issued) expect(all).not.toContain(token); + }); +}); diff --git a/packages/dewee-webchat/tsup.config.ts b/packages/dewee-webchat/tsup.config.ts index aaf1029..0b546a2 100644 --- a/packages/dewee-webchat/tsup.config.ts +++ b/packages/dewee-webchat/tsup.config.ts @@ -17,11 +17,11 @@ const esbuildPlugins = [ export default defineConfig([ { - entry: { index: "src/index.ts", server: "src/server/index.ts", react: "src/react.ts", vue: "src/vue.ts" }, + entry: { index: "src/index.ts", server: "src/server/index.ts", "server-identity": "src/server/identity.ts", react: "src/react.ts", vue: "src/vue.ts" }, format: ["esm"], dts: true, target: "es2022", - external: ["react", "vue"], + external: ["react", "vue", "@dewee/identity"], esbuildPlugins, }, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69d5b5c..be5cfc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,10 +12,10 @@ importers: dependencies: '@astrojs/cloudflare': specifier: ^14.3.4 - version: 14.3.4(@types/node@22.20.5)(astro@7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(esbuild@0.28.2)(terser@5.36.0)(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5))(yaml@2.9.1) + version: 14.3.4(@types/node@22.20.5)(astro@7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(esbuild@0.28.2)(terser@5.51.2)(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5))(yaml@2.9.1) astro: specifier: ^7.3.8 - version: 7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + version: 7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) dewee-webchat: specifier: workspace:* version: link:../../packages/dewee-webchat @@ -100,7 +100,7 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.63.1 - version: 1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5)) + version: 1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5)) '@types/react': specifier: ^19.3.0 version: 19.3.0 @@ -109,13 +109,13 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.1.2 - version: 6.1.2(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1)) + version: 6.1.2(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1)) typescript: specifier: ^5.9.3 version: 5.9.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + version: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) wrangler: specifier: ^4.149.0 version: 4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5) @@ -131,16 +131,16 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.63.1 - version: 1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5)) + version: 1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5)) '@vitejs/plugin-vue': specifier: ^6.0.10 - version: 6.0.10(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3)) + version: 6.0.10(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3)) typescript: specifier: ^5.9.3 version: 5.9.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + version: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) vue-tsc: specifier: ^3.3.12 version: 3.3.12(typescript@5.9.3) @@ -150,6 +150,9 @@ importers: packages/dewee-webchat: devDependencies: + '@dewee/identity': + specifier: file:../../vendor/dewee-identity-0.1.0-beta.0.tgz + version: file:vendor/dewee-identity-0.1.0-beta.0.tgz '@types/node': specifier: ^22.15.0 version: 22.20.5 @@ -159,6 +162,9 @@ importers: '@types/ws': specifier: ^8.18.2 version: 8.18.2 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 happy-dom: specifier: ^18.0.1 version: 18.0.1 @@ -173,10 +179,13 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@22.20.5)(happy-dom@18.0.1)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1) + version: 3.2.7(@types/node@22.20.5)(happy-dom@18.0.1)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1) vue: specifier: ^3.5.18 version: 3.5.43(typescript@5.9.3) + webpack: + specifier: ^5.112.0 + version: 5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29) ws: specifier: ^8.22.0 version: 8.22.0 @@ -641,6 +650,11 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@dewee/identity@file:vendor/dewee-identity-0.1.0-beta.0.tgz': + resolution: {integrity: sha512-IGFRWs/LMQDz61DZBZifRuMQcjygZJcvo1PoP1FUYYRvw+YM5B/cryZyUK/Sg8tGxFxUrpduk9dEjHC/GC573w==, tarball: file:vendor/dewee-identity-0.1.0-beta.0.tgz} + version: 0.1.0-beta.0 + engines: {node: '>=20.3'} + '@dotenvx/dotenvx@2.34.2': resolution: {integrity: sha512-vU83L7W77P4LPpzbkicEraleqEwh6a1mcyRvzZA4D/786mYAQH3yad7be86jx9voAuyWx1QhDcJ8h+Dzzt++ng==} hasBin: true @@ -1697,6 +1711,9 @@ packages: '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1826,6 +1843,57 @@ packages: '@vue/shared@3.5.43': resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -1843,6 +1911,22 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + alien-signals@3.2.1: resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} @@ -1923,6 +2007,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + baseline-browser-mapping@2.11.30: + resolution: {integrity: sha512-YJs6Pz17RMNv8HoyKREtCY38E3BV1nPkWVmIb+XN1uHC1Vqt3KZCH/uJH9ZKRoiKb/icZ6tvehyee6RZNbFfRA==} + engines: {node: '>=6.0.0'} + hasBin: true + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} @@ -1943,6 +2032,11 @@ packages: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} + browserslist@4.29.4: + resolution: {integrity: sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1971,6 +2065,9 @@ packages: caniuse-lite@1.0.30001815: resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} + caniuse-lite@1.0.30001817: + resolution: {integrity: sha512-uZ1JG80BwhtMalIAGPxPfmNhHyd/B/nTdr3hITWlRamSyAdUStP1Gop01e7SD5q6J90ixJvWDbvmiFVfDgn4bw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2000,6 +2097,10 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -2189,6 +2290,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.461: + resolution: {integrity: sha512-3WZFARz1By+/rYcqQ0OLj4UcEdhd6tz2LEcDtKBUq7ey77EeJ1OtetY/5hjSUxw7dC5r5mA7FfkhRbqO9vnTCg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2202,6 +2306,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + enhanced-resolve@5.26.0: + resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} + engines: {node: '>=10.13.0'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -2228,6 +2336,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-module-lexer@3.0.3: resolution: {integrity: sha512-yWGe1O/j+almnV4hOYrmZo8u58JdnmXEMneRQAYkWtqSiT86yoMIEKF4rpqEDj52tbYl3xceBD3DQIBwcsC9Pw==} @@ -2278,6 +2389,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -2289,12 +2404,18 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} + fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -2397,6 +2518,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} @@ -2408,6 +2532,10 @@ packages: resolution: {integrity: sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==} engines: {node: '>=20.0.0'} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2483,6 +2611,13 @@ packages: resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} engines: {node: '>=14'} + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jose@6.2.13: + resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -2490,6 +2625,9 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} @@ -2618,6 +2756,9 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} @@ -2661,6 +2802,61 @@ packages: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} + minimizer-webpack-plugin@5.13.1: + resolution: {integrity: sha512-1mhAfvachWYYOm8tpmany5NLuvP+F2wxZWBSX8DC0uFS2wJpM9q3rWgN9wBooAH9d5V12wX0BjyJe4fMjHMarQ==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@napi-rs/image': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + imagemin: '*' + lightningcss: '*' + postcss: '*' + sharp: '*' + svgo: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@napi-rs/image': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + imagemin: + optional: true + lightningcss: + optional: true + postcss: + optional: true + sharp: + optional: true + svgo: + optional: true + uglify-js: + optional: true + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -2746,6 +2942,10 @@ packages: node-mock-http@1.0.5: resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} + node-releases@2.0.59: + resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} + engines: {node: '>=18'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -2937,6 +3137,10 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -2974,6 +3178,16 @@ packages: scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + schema-utils@4.8.1: + resolution: {integrity: sha512-x39pYlJPyH7Uge5QO64B9XGP9zLDc9WNmiQZc+CE3IzZPvMu3lHOuAMGpYNoPixEL7MSWlGhYTg5UlLSL8G6iw==} + engines: {node: '>= 10.13.0'} + hasBin: true + peerDependencies: + webpack: ^5.112.0 + peerDependenciesMeta: + webpack: + optional: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -3121,16 +3335,29 @@ packages: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + svgo@4.1.0: resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} engines: {node: '>=16'} hasBin: true + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + terser@5.36.0: resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==} engines: {node: '>=10'} hasBin: true + terser@5.51.2: + resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==} + engines: {node: '>=10'} + hasBin: true + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -3342,6 +3569,12 @@ packages: uploadthing: optional: true + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} @@ -3500,6 +3733,10 @@ packages: typescript: optional: true + watchpack@2.6.3: + resolution: {integrity: sha512-s3NZy0QpTY9qGqzc0wtz4qI28z3+5qKbbAec4MV4G9N3CfgZ9wgvQRr0cl2jh/tqkWG1NWHkmVMHZpCamev5Wg==} + engines: {node: '>=10.13.0'} + web-streams-polyfill@4.0.0-beta.3: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} @@ -3507,6 +3744,20 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webpack-sources@3.6.0: + resolution: {integrity: sha512-EIMmPVvNI0CYXyRPp34F2Qk7W37/BZVZIofS6LAUnrNjCVahS+hO4mxwFJDjQjODTGZDtVzLYoN2+1dYkhk9qA==} + engines: {node: '>=10.13.0'} + + webpack@5.112.0: + resolution: {integrity: sha512-NIBpsRuCll036haDDt/CfcTDfsWNR9Z782nMRMI9D377BsGcYzZDTNU5TyzhCJzPIcihAxMBaMAhq2WSaDrH8Q==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -3667,14 +3918,14 @@ snapshots: '@ast-grep/napi-win32-ia32-msvc': 0.45.3 '@ast-grep/napi-win32-x64-msvc': 0.45.3 - '@astrojs/cloudflare@14.3.4(@types/node@22.20.5)(astro@7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(esbuild@0.28.2)(terser@5.36.0)(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5))(yaml@2.9.1)': + '@astrojs/cloudflare@14.3.4(@types/node@22.20.5)(astro@7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(esbuild@0.28.2)(terser@5.51.2)(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5))(yaml@2.9.1)': dependencies: '@astrojs/internal-helpers': 0.12.0 '@astrojs/underscore-redirects': 1.0.4 - '@cloudflare/vite-plugin': 1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5)) - astro: 7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + '@cloudflare/vite-plugin': 1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5)) + astro: 7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) piccolore: 0.1.3 - vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) wrangler: 4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5) transitivePeerDependencies: - '@types/node' @@ -4374,12 +4625,12 @@ snapshots: optionalDependencies: workerd: 1.20261006.1 - '@cloudflare/vite-plugin@1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5))': + '@cloudflare/vite-plugin@1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5))': dependencies: '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261006.1) miniflare: 5.20261006.1-alpha(@types/node@22.20.5) unenv: 2.0.0-rc.24 - vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) workerd: 1.20261006.1 wrangler: 4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5) ws: 8.21.0 @@ -4409,6 +4660,10 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@dewee/identity@file:vendor/dewee-identity-0.1.0-beta.0.tgz': + dependencies: + jose: 6.2.13 + '@dotenvx/dotenvx@2.34.2': dependencies: '@dotenvx/primitives': 3.3.0 @@ -5259,6 +5514,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -5302,15 +5559,15 @@ snapshots: '@ungap/structured-clone@1.4.0': {} - '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))': + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) - '@vitejs/plugin-vue@6.0.10(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.10(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) vue: 3.5.43(typescript@5.9.3) '@vitest/expect@3.2.7': @@ -5321,13 +5578,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.7(vite@7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1))': + '@vitest/mocker@3.2.7(vite@7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1) + vite: 7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1) '@vitest/pretty-format@3.2.7': dependencies: @@ -5431,6 +5688,86 @@ snapshots: '@vue/shared@3.5.43': {} + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -5446,6 +5783,22 @@ snapshots: dependencies: humanize-ms: 1.2.1 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-keywords@5.1.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + fast-deep-equal: 3.1.3 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.9 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + alien-signals@3.2.1: {} am-i-vibing@0.5.0: @@ -5477,7 +5830,7 @@ snapshots: assertion-error@2.0.1: {} - astro@7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1): + astro@7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1): dependencies: '@astrojs/compiler-rs': 0.5.1 '@astrojs/internal-helpers': 0.12.0 @@ -5526,8 +5879,8 @@ snapshots: unifont: 0.7.5 unstorage: 1.17.5(aws4fetch@1.0.20) verkit: 0.5.0 - vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) - vitefu: 1.1.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1)) + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) + vitefu: 1.1.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.6.5 @@ -5580,6 +5933,8 @@ snapshots: baseline-browser-mapping@2.11.28: {} + baseline-browser-mapping@2.11.30: {} + blake3-wasm@2.1.5: {} body-parser@2.3.0: @@ -5608,6 +5963,14 @@ snapshots: dependencies: balanced-match: 4.0.4 + browserslist@4.29.4: + dependencies: + baseline-browser-mapping: 2.11.30 + caniuse-lite: 1.0.30001817 + electron-to-chromium: 1.5.461 + node-releases: 2.0.59 + update-browserslist-db: 1.3.4(browserslist@4.29.4) + buffer-from@1.1.2: {} bundle-require@5.1.0(esbuild@0.27.7): @@ -5631,6 +5994,8 @@ snapshots: caniuse-lite@1.0.30001815: {} + caniuse-lite@1.0.30001817: {} + ccount@2.0.1: {} chai@5.3.3: @@ -5657,6 +6022,8 @@ snapshots: dependencies: readdirp: 5.1.1 + chrome-trace-event@1.0.4: {} + ci-info@4.4.0: {} client-only@0.0.1: {} @@ -5818,6 +6185,8 @@ snapshots: ee-first@1.1.1: {} + electron-to-chromium@1.5.461: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -5826,6 +6195,11 @@ snapshots: encodeurl@2.0.0: {} + enhanced-resolve@5.26.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -5843,6 +6217,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.3.2: {} + es-module-lexer@3.0.3: {} es-object-atoms@1.1.2: @@ -5932,6 +6308,8 @@ snapshots: eventemitter3@5.0.4: {} + events@3.3.0: {} + expect-type@1.4.0: {} express@5.3.0: @@ -5969,12 +6347,16 @@ snapshots: extend@3.0.2: {} + fast-deep-equal@3.1.3: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: dependencies: fast-string-truncated-width: 3.0.3 + fast-uri@3.1.9: {} + fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 @@ -6085,6 +6467,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + gzip-size@6.0.0: dependencies: duplexer: 0.1.2 @@ -6107,6 +6491,8 @@ snapshots: '@types/whatwg-mimetype': 3.0.2 whatwg-mimetype: 3.0.0 + has-flag@4.0.0: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -6181,10 +6567,20 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jest-worker@27.5.1: + dependencies: + '@types/node': 22.20.5 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jose@6.2.13: {} + joycon@3.1.1: {} js-tokens@9.0.1: {} + json-schema-traverse@1.0.0: {} + jsonc-parser@3.3.1: {} kleur@4.1.5: {} @@ -6286,6 +6682,8 @@ snapshots: merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} + micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 @@ -6336,6 +6734,18 @@ snapshots: dependencies: brace-expansion: 2.1.7 + minimizer-webpack-plugin@5.13.1(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)(webpack@5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.8.1(webpack@5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)) + terser: 5.51.2 + webpack: 5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29) + optionalDependencies: + esbuild: 0.28.2 + lightningcss: 1.33.0 + postcss: 8.5.29 + minipass@7.1.3: {} mkdirp@3.0.1: {} @@ -6410,6 +6820,8 @@ snapshots: node-mock-http@1.0.5: {} + node-releases@2.0.59: {} + normalize-path@3.0.0: {} nth-check@2.1.1: @@ -6568,6 +6980,8 @@ snapshots: dependencies: regex-utilities: 2.3.0 + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -6664,6 +7078,15 @@ snapshots: scheduler@0.28.0: {} + schema-utils@4.8.1(webpack@5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)): + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + ajv-keywords: 5.1.0(ajv@8.20.0) + optionalDependencies: + webpack: 5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29) + semver@7.8.5: {} send@1.2.1: @@ -6855,6 +7278,10 @@ snapshots: supports-color@10.2.2: {} + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + svgo@4.1.0: dependencies: commander: 11.1.0 @@ -6865,6 +7292,8 @@ snapshots: picocolors: 1.1.1 sax: 1.6.1 + tapable@2.3.3: {} + terser@5.36.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -6872,6 +7301,13 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + terser@5.51.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.19.0 + commander: 2.20.3 + source-map-support: 0.5.21 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -7027,6 +7463,12 @@ snapshots: optionalDependencies: aws4fetch: 1.0.20 + update-browserslist-db@1.3.4(browserslist@4.29.4): + dependencies: + browserslist: 4.29.4 + escalade: 3.2.0 + picocolors: 1.1.1 + urlpattern-polyfill@10.1.0: {} vary@1.1.2: {} @@ -7043,13 +7485,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1): + vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1) + vite: 7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1) transitivePeerDependencies: - '@types/node' - jiti @@ -7064,7 +7506,7 @@ snapshots: - tsx - yaml - vite@7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1): + vite@7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1): dependencies: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.7) @@ -7076,10 +7518,10 @@ snapshots: '@types/node': 22.20.5 fsevents: 2.3.3 lightningcss: 1.33.0 - terser: 5.36.0 + terser: 5.51.2 yaml: 2.9.1 - vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1): + vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -7090,18 +7532,18 @@ snapshots: '@types/node': 22.20.5 esbuild: 0.28.2 fsevents: 2.3.3 - terser: 5.36.0 + terser: 5.51.2 yaml: 2.9.1 - vitefu@1.1.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1)): + vitefu@1.1.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1)): optionalDependencies: - vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) - vitest@3.2.7(@types/node@22.20.5)(happy-dom@18.0.1)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1): + vitest@3.2.7(@types/node@22.20.5)(happy-dom@18.0.1)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1)) + '@vitest/mocker': 3.2.7(vite@7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -7119,8 +7561,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1) - vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1) + vite: 7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1) + vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.5 @@ -7157,10 +7599,53 @@ snapshots: optionalDependencies: typescript: 5.9.3 + watchpack@2.6.3: + dependencies: + graceful-fs: 4.2.11 + web-streams-polyfill@4.0.0-beta.3: {} webidl-conversions@3.0.1: {} + webpack-sources@3.6.0: {} + + webpack@5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29): + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + browserslist: 4.29.4 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.26.0 + es-module-lexer: 2.3.2 + events: 3.3.0 + graceful-fs: 4.2.11 + mime-db: 1.54.0 + minimizer-webpack-plugin: 5.13.1(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)(webpack@5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)) + schema-utils: 4.8.1(webpack@5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)) + tapable: 2.3.3 + watchpack: 2.6.3 + webpack-sources: 3.6.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@napi-rs/image' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - imagemin + - lightningcss + - postcss + - sharp + - svgo + - uglify-js + whatwg-mimetype@3.0.0: {} whatwg-url@5.0.0: diff --git a/vendor/dewee-identity-0.1.0-beta.0.tgz b/vendor/dewee-identity-0.1.0-beta.0.tgz new file mode 100644 index 0000000..03a8dc2 Binary files /dev/null and b/vendor/dewee-identity-0.1.0-beta.0.tgz differ