affine-mcp-server 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # AFFiNE MCP Server
2
2
 
3
- A Model Context Protocol (MCP) server that integrates with AFFiNE (self‑hosted or cloud). It exposes AFFiNE workspaces and documents to AI assistants over stdio.
3
+ A Model Context Protocol (MCP) server that integrates with AFFiNE (self‑hosted or cloud). It exposes AFFiNE workspaces and documents to AI assistants over stdio (default) or HTTP (`/mcp`).
4
4
 
5
- [![Version](https://img.shields.io/badge/version-1.5.0-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
5
+ [![Version](https://img.shields.io/badge/version-1.7.0-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
6
6
  [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-1.17.2-green)](https://github.com/modelcontextprotocol/typescript-sdk)
7
7
  [![CI](https://github.com/dawncr0w/affine-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/dawncr0w/affine-mcp-server/actions/workflows/ci.yml)
8
8
  [![License](https://img.shields.io/badge/license-MIT-yellow)](LICENSE)
@@ -14,17 +14,18 @@ A Model Context Protocol (MCP) server that integrates with AFFiNE (self‑hosted
14
14
  ## Overview
15
15
 
16
16
  - Purpose: Manage AFFiNE workspaces and documents through MCP
17
- - Transport: stdio only (Claude Desktop / Codex compatible)
17
+ - Transport: stdio (default) and optional HTTP (`/mcp`) for remote MCP deployments
18
18
  - Auth: Token, Cookie, or Email/Password (priority order)
19
- - Tools: 32 focused tools with WebSocket-based document editing
19
+ - Tools: 43 focused tools with WebSocket-based document editing
20
20
  - Status: Active
21
21
 
22
- > New in v1.5.0: `append_block` now supports 30 verified block profiles, including database/edgeless (`frame`, `edgeless_text`, `surface_ref`, `note`) insertion paths. For stability on AFFiNE 0.26.x, `type=\"data_view\"` is currently mapped to a database block.
22
+ > New in v1.7.0: Added remote HTTP MCP support (`/mcp`) with token/CORS controls, while retaining legacy SSE compatibility (`/sse`, `/messages`) for older clients.
23
23
 
24
24
  ## Features
25
25
 
26
26
  - Workspace: create (with initial doc), read, update, delete
27
- - Documents: list/get/read/publish/revoke + create/append paragraph/delete (WebSocket‑based)
27
+ - Documents: list/get/read/publish/revoke + create/append/replace/delete + markdown import/export + tags (WebSocket‑based)
28
+ - Database workflows: create database blocks, then add columns/rows via MCP tools
28
29
  - Comments: full CRUD and resolve
29
30
  - Version History: list
30
31
  - Users & Tokens: current user, sign in, profile/settings, and personal access tokens
@@ -53,17 +54,95 @@ Note: From v1.2.2+ the CLI wrapper (`bin/affine-mcp`) ensures Node runs the ESM
53
54
 
54
55
  ## Configuration
55
56
 
56
- Configure via environment variables (shell or app config). `.env` files are no longer recommended.
57
+ ### Interactive login (recommended)
58
+
59
+ The easiest way to configure credentials:
60
+
61
+ ```bash
62
+ npm i -g affine-mcp-server
63
+ affine-mcp login
64
+ ```
65
+
66
+ This stores credentials in `~/.config/affine-mcp/config` (mode 600). The MCP server reads them automatically — no environment variables needed.
67
+
68
+ **AFFiNE Cloud** (`app.affine.pro`): you'll be prompted to paste an API token from Settings → Integrations → MCP Server.
69
+
70
+ **Self-hosted instances**: you can choose between email/password (recommended — auto-generates an API token) or pasting a token manually.
71
+
72
+ ```
73
+ $ affine-mcp login
74
+ Affine MCP Server — Login
75
+
76
+ Affine URL [https://app.affine.pro]: https://my-affine.example.com
77
+
78
+ Auth method — [1] Email/password (recommended) [2] Paste API token: 1
79
+ Email: user@example.com
80
+ Password: ****
81
+ Signing in...
82
+ ✓ Signed in as: User Name <user@example.com>
83
+
84
+ Generating API token...
85
+ ✓ Created token: ut_abc123... (name: affine-mcp-2026-02-18)
86
+
87
+ Detecting workspaces...
88
+ Found 1 workspace: abc-def-123 (by User Name, 1 member, 2/10/2026)
89
+ Auto-selected.
90
+
91
+ ✓ Saved to /home/user/.config/affine-mcp/config (mode 600)
92
+ The MCP server will use these credentials automatically.
93
+ ```
94
+
95
+ Other CLI commands:
96
+ - `affine-mcp status` — show current config and test connection
97
+ - `affine-mcp logout` — remove stored credentials
98
+
99
+ ### Environment variables
100
+
101
+ You can also configure via environment variables (they override the config file):
57
102
 
58
103
  - Required: `AFFINE_BASE_URL`
59
104
  - Auth (choose one): `AFFINE_API_TOKEN` | `AFFINE_COOKIE` | `AFFINE_EMAIL` + `AFFINE_PASSWORD`
60
- - Optional: `AFFINE_GRAPHQL_PATH` (default `/graphql`), `AFFINE_WORKSPACE_ID`, `AFFINE_LOGIN_AT_START` (`async` default, `sync` to block)
105
+ - Optional: `AFFINE_GRAPHQL_PATH` (default `/graphql`), `AFFINE_WORKSPACE_ID`, `AFFINE_LOGIN_AT_START` (set `sync` only when you must block startup)
61
106
 
62
107
  Authentication priority:
63
108
  1) `AFFINE_API_TOKEN` → 2) `AFFINE_COOKIE` → 3) `AFFINE_EMAIL` + `AFFINE_PASSWORD`
64
109
 
110
+ > **Cloudflare note**: `AFFINE_EMAIL`/`AFFINE_PASSWORD` auth requires programmatic access to `/api/auth/sign-in`. AFFiNE Cloud (`app.affine.pro`) is behind Cloudflare, which blocks these requests. Use `AFFINE_API_TOKEN` for cloud, or use `affine-mcp login` which handles this automatically. Email/password works for self-hosted instances without Cloudflare.
111
+
65
112
  ## Quick Start
66
113
 
114
+ ### Claude Code
115
+
116
+ After running `affine-mcp login`, add to your project's `.mcp.json`:
117
+
118
+ ```json
119
+ {
120
+ "mcpServers": {
121
+ "affine": {
122
+ "command": "affine-mcp"
123
+ }
124
+ }
125
+ }
126
+ ```
127
+
128
+ No `env` block needed — the server reads `~/.config/affine-mcp/config` automatically.
129
+
130
+ If you prefer explicit env vars instead of the config file:
131
+
132
+ ```json
133
+ {
134
+ "mcpServers": {
135
+ "affine": {
136
+ "command": "affine-mcp",
137
+ "env": {
138
+ "AFFINE_BASE_URL": "https://app.affine.pro",
139
+ "AFFINE_API_TOKEN": "ut_xxx"
140
+ }
141
+ }
142
+ }
143
+ }
144
+ ```
145
+
67
146
  ### Claude Desktop
68
147
 
69
148
  Add to your Claude Desktop configuration:
@@ -78,10 +157,25 @@ Add to your Claude Desktop configuration:
78
157
  "affine": {
79
158
  "command": "affine-mcp",
80
159
  "env": {
81
- "AFFINE_BASE_URL": "https://your-affine-instance.com",
160
+ "AFFINE_BASE_URL": "https://app.affine.pro",
161
+ "AFFINE_API_TOKEN": "ut_xxx"
162
+ }
163
+ }
164
+ }
165
+ }
166
+ ```
167
+
168
+ Or with email/password for self-hosted instances (not supported on AFFiNE Cloud — see Cloudflare note above):
169
+
170
+ ```json
171
+ {
172
+ "mcpServers": {
173
+ "affine": {
174
+ "command": "affine-mcp",
175
+ "env": {
176
+ "AFFINE_BASE_URL": "https://your-self-hosted-affine.com",
82
177
  "AFFINE_EMAIL": "you@example.com",
83
- "AFFINE_PASSWORD": "secret!",
84
- "AFFINE_LOGIN_AT_START": "async"
178
+ "AFFINE_PASSWORD": "secret!"
85
179
  }
86
180
  }
87
181
  }
@@ -89,28 +183,21 @@ Add to your Claude Desktop configuration:
89
183
  ```
90
184
 
91
185
  Tips
92
- - Prefer `AFFINE_COOKIE` or `AFFINE_API_TOKEN` for zero‑latency startup.
186
+ - Prefer `affine-mcp login` or `AFFINE_API_TOKEN` for zero‑latency startup.
93
187
  - If your password contains `!` (zsh history expansion), wrap it in single quotes in shells or use the JSON config above.
94
188
 
95
189
  ### Codex CLI
96
190
 
97
191
  Register the MCP server with Codex:
98
192
 
99
- - Global install path (fastest)
100
- - `npm i -g affine-mcp-server`
101
- - `codex mcp add affine --env AFFINE_BASE_URL=https://your-affine-instance.com --env 'AFFINE_EMAIL=you@example.com' --env 'AFFINE_PASSWORD=secret!' --env AFFINE_LOGIN_AT_START=async -- affine-mcp`
102
-
103
- - Use npx (no global install)
104
- - `codex mcp add affine --env AFFINE_BASE_URL=https://your-affine-instance.com --env 'AFFINE_EMAIL=you@example.com' --env 'AFFINE_PASSWORD=secret!' --env AFFINE_LOGIN_AT_START=async -- npx -y -p affine-mcp-server affine-mcp`
193
+ - With config file (after `affine-mcp login`):
194
+ - `codex mcp add affine -- affine-mcp`
105
195
 
106
- - Token or cookie (no startup login)
107
- - Token: `codex mcp add affine --env AFFINE_BASE_URL=https://... --env AFFINE_API_TOKEN=... -- affine-mcp`
108
- - Cookie: `codex mcp add affine --env AFFINE_BASE_URL=https://... --env "AFFINE_COOKIE=affine_session=...; affine_csrf=..." -- affine-mcp`
196
+ - With API token:
197
+ - `codex mcp add affine --env AFFINE_BASE_URL=https://app.affine.pro --env AFFINE_API_TOKEN=ut_xxx -- affine-mcp`
109
198
 
110
- Notes
111
- - MCP name: `affine`
112
- - Command: `affine-mcp`
113
- - Environment: `AFFINE_BASE_URL` + one auth method (`AFFINE_API_TOKEN` | `AFFINE_COOKIE` | `AFFINE_EMAIL`/`AFFINE_PASSWORD`)
199
+ - With email/password (self-hosted only):
200
+ - `codex mcp add affine --env AFFINE_BASE_URL=https://your-self-hosted-affine.com --env 'AFFINE_EMAIL=you@example.com' --env 'AFFINE_PASSWORD=secret!' -- affine-mcp`
114
201
 
115
202
  ### Cursor
116
203
 
@@ -124,8 +211,8 @@ Project-local (`.cursor/mcp.json`) example:
124
211
  "affine": {
125
212
  "command": "affine-mcp",
126
213
  "env": {
127
- "AFFINE_BASE_URL": "https://your-affine-instance.com",
128
- "AFFINE_API_TOKEN": "apt_xxx"
214
+ "AFFINE_BASE_URL": "https://app.affine.pro",
215
+ "AFFINE_API_TOKEN": "ut_xxx"
129
216
  }
130
217
  }
131
218
  }
@@ -141,14 +228,78 @@ If you prefer `npx`:
141
228
  "command": "npx",
142
229
  "args": ["-y", "-p", "affine-mcp-server", "affine-mcp"],
143
230
  "env": {
144
- "AFFINE_BASE_URL": "https://your-affine-instance.com",
145
- "AFFINE_API_TOKEN": "apt_xxx"
231
+ "AFFINE_BASE_URL": "https://app.affine.pro",
232
+ "AFFINE_API_TOKEN": "ut_xxx"
146
233
  }
147
234
  }
148
235
  }
149
236
  }
150
237
  ```
151
238
 
239
+ ### Remote Server
240
+
241
+ If you want to host the server remotely (e.g., using Render, Railway, Docker, or a VPS) and connect via HTTP MCP (Streamable HTTP on `/mcp`) instead of local `stdio`, run the server in HTTP mode.
242
+
243
+ #### Environment variables (HTTP mode)
244
+
245
+ Required:
246
+ - `MCP_TRANSPORT=http`
247
+ - `AFFINE_BASE_URL` (example: `https://app.affine.pro`)
248
+ - One auth method:
249
+ - `AFFINE_API_TOKEN` (recommended), or `AFFINE_COOKIE`, or `AFFINE_EMAIL` + `AFFINE_PASSWORD`
250
+
251
+ Recommended for remote/public deployments:
252
+ - `AFFINE_MCP_HTTP_HOST=0.0.0.0`
253
+ - `AFFINE_MCP_HTTP_TOKEN=<strong-random-token>` (protects `/mcp`, `/sse`, `/messages`)
254
+ - `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=<comma-separated-origins>` (for browser clients)
255
+
256
+ Optional:
257
+ - `PORT` (defaults to `3000`; many platforms like Render inject this automatically)
258
+ - `AFFINE_WORKSPACE_ID`
259
+ - `AFFINE_GRAPHQL_PATH` (defaults to `/graphql`)
260
+ - `AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS=true` (testing only)
261
+
262
+ ```bash
263
+ # Export your configuration first
264
+ export MCP_TRANSPORT=http
265
+ export AFFINE_API_TOKEN="your_token..."
266
+ export AFFINE_MCP_HTTP_HOST="0.0.0.0" # Default: 127.0.0.1
267
+ export AFFINE_MCP_HTTP_TOKEN="your-super-secret-token"
268
+ export PORT=3000
269
+
270
+ # Start in HTTP mode (Streamable HTTP on /mcp)
271
+ npm run start:http
272
+ # OR manually:
273
+ # MCP_TRANSPORT=http node dist/index.js
274
+ # ("sse" is still accepted at /sse)
275
+ ```
276
+
277
+ #### Recommended presets
278
+
279
+ Local testing (HTTP mode):
280
+ - `MCP_TRANSPORT=http`
281
+ - `AFFINE_MCP_HTTP_HOST=127.0.0.1`
282
+ - `AFFINE_MCP_HTTP_TOKEN=<token>` (recommended even locally)
283
+ - `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=http://localhost:3000` (if testing from a browser app)
284
+
285
+ Docker / container runtime:
286
+ - `MCP_TRANSPORT=http`
287
+ - `AFFINE_MCP_HTTP_HOST=0.0.0.0`
288
+ - `PORT=3000` (or container/platform port)
289
+ - `AFFINE_MCP_HTTP_TOKEN=<strong-token>`
290
+ - `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=<your app origin(s)>`
291
+
292
+ Render / Railway / VPS (public endpoint):
293
+ - `MCP_TRANSPORT=http`
294
+ - `AFFINE_MCP_HTTP_HOST=0.0.0.0`
295
+ - `AFFINE_MCP_HTTP_TOKEN=<strong-token>`
296
+ - `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=<your client origin(s)>`
297
+
298
+ Endpoints currently available:
299
+ - `/mcp` - MCP server (Streamable HTTP)
300
+ - `/sse` - SSE endpoint (old protocol compatible)
301
+ - `/messages` - Messages endpoint (old protocol compatible)
302
+
152
303
  ## Available Tools
153
304
 
154
305
  ### Workspace
@@ -159,14 +310,25 @@ If you prefer `npx`:
159
310
  - `delete_workspace` – delete workspace permanently
160
311
 
161
312
  ### Documents
162
- - `list_docs` – list documents with pagination
313
+ - `list_docs` – list documents with pagination (includes `node.tags`)
314
+ - `list_tags` – list all tags in a workspace
315
+ - `list_docs_by_tag` – list documents by tag
163
316
  - `get_doc` – get document metadata
164
317
  - `read_doc` – read document block content and plain text snapshot (WebSocket)
318
+ - `export_doc_markdown` – export document content as markdown
165
319
  - `publish_doc` – make document public
166
320
  - `revoke_doc` – revoke public access
167
321
  - `create_doc` – create a new document (WebSocket)
322
+ - `create_doc_from_markdown` – create a document from markdown content
323
+ - `create_tag` – create a reusable workspace-level tag
324
+ - `add_tag_to_doc` – attach a tag to a document
325
+ - `remove_tag_from_doc` – detach a tag from a document
168
326
  - `append_paragraph` – append a paragraph block (WebSocket)
169
327
  - `append_block` – append canonical block types (text/list/code/media/embed/database/edgeless) with strict validation and placement control (`data_view` currently falls back to database)
328
+ - `add_database_column` – add a column to a database block (`rich-text`, `select`, `multi-select`, `number`, `checkbox`, `link`, `date`)
329
+ - `add_database_row` – add a row to a database block with values mapped by column name/ID
330
+ - `append_markdown` – append markdown content to an existing document
331
+ - `replace_doc_with_markdown` – replace the main note content with markdown content
170
332
  - `delete_doc` – delete a document (WebSocket)
171
333
 
172
334
  ### Comments
@@ -210,13 +372,17 @@ npm run pack:check
210
372
 
211
373
  - `tool-manifest.json` is the source of truth for publicly exposed tool names.
212
374
  - CI validates that `registerTool(...)` declarations match the manifest exactly.
375
+ - For full tool-surface verification, run `npm run test:comprehensive`.
376
+ - For full environment verification, run `npm run test:e2e` (Docker + MCP + Playwright).
377
+ - Additional focused runners: `npm run test:db-create`, `npm run test:bearer`, `npm run test:playwright`.
213
378
 
214
379
  ## Troubleshooting
215
380
 
216
381
  Authentication
217
- - Email/Password: ensure your instance allows password auth and credentials are valid
382
+ - **Cloudflare (403 "Just a moment...")**: AFFiNE Cloud (`app.affine.pro`) uses Cloudflare protection, which blocks programmatic sign-in via `/api/auth/sign-in`. Use `AFFINE_API_TOKEN` instead, or run `affine-mcp login` which guides you through the right method automatically. Email/password auth only works for self-hosted instances.
383
+ - Email/Password: only works on self-hosted instances without Cloudflare. Ensure your instance allows password auth and credentials are valid.
218
384
  - Cookie: copy cookies (e.g., `affine_session`, `affine_csrf`) from the browser DevTools after login
219
- - Token: generate a personal access token; verify it hasn't expired
385
+ - Token: generate a personal access token; verify it hasn't expired. Run `affine-mcp status` to test.
220
386
  - Startup timeouts: v1.2.2+ includes a CLI wrapper fix and default async login to avoid blocking the MCP handshake. Set `AFFINE_LOGIN_AT_START=sync` only if needed.
221
387
 
222
388
  Connection
@@ -243,6 +409,21 @@ Workspace visibility
243
409
 
244
410
  ## Version History
245
411
 
412
+ ### 1.7.0 (2026‑02‑27)
413
+ - Added Streamable HTTP MCP support on `/mcp` for remote hosting while keeping legacy SSE compatibility paths (`/sse`, `/messages`)
414
+ - Added HTTP deployment controls: `AFFINE_MCP_HTTP_HOST`, `AFFINE_MCP_HTTP_TOKEN`, `AFFINE_MCP_HTTP_ALLOWED_ORIGINS`, `AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS`
415
+ - Added `npm run start:http` for one-command HTTP mode startup
416
+ - Hardened HTTP request handling with explicit 50MB parser application and case-insensitive Bearer auth parsing
417
+ - Expanded docs with remote deployment/security presets (Docker, Render, Railway, VPS)
418
+ - Verified full release checks with `npm run ci`, `npm run test:e2e`, and `npm run test:comprehensive`
419
+
420
+ ### 1.6.0 (2026‑02‑24)
421
+ - Added 11 document workflow tools: tags (`list_tags`, `list_docs_by_tag`, `create_tag`, `add_tag_to_doc`, `remove_tag_from_doc`), markdown roundtrip (`export_doc_markdown`, `create_doc_from_markdown`, `append_markdown`, `replace_doc_with_markdown`), and database operations (`add_database_column`, `add_database_row`)
422
+ - Added interactive CLI commands: `affine-mcp login`, `affine-mcp status`, `affine-mcp logout`
423
+ - Added Docker + Playwright E2E pipeline and CI workflow for auth/database regression checks
424
+ - Tool surface increased from 32 to 43 canonical tools
425
+ - Added release test commands (`test:e2e`, `test:db-create`, `test:bearer`, `test:playwright`) and package dependencies for markdown conversion + Playwright
426
+
246
427
  ### 1.5.0 (2026‑02‑13)
247
428
  - Expanded `append_block` from Step1 to Step4 profiles: canonical text/list/code/divider/callout/latex/table/bookmark/media/embed plus `database`, `data_view`, `surface_ref`, `frame`, `edgeless_text`, `note` (`data_view` currently mapped to database for stability)
248
429
  - Added strict field validation and canonical parent enforcement for page/note/surface containers
@@ -266,7 +447,7 @@ Workspace visibility
266
447
 
267
448
  ### 1.2.1 (2025‑09‑17)
268
449
  - Default to asynchronous email/password login after MCP stdio handshake
269
- - New `AFFINE_LOGIN_AT_START` env (`async` default, `sync` to block at startup)
450
+ - `AFFINE_LOGIN_AT_START` supports `sync` when you need blocking startup (default is non-blocking)
270
451
  - Expanded docs for Codex/Claude using npm, npx, and local clone
271
452
 
272
453
  ### 1.2.0 (2025‑09‑16)
package/dist/auth.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { fetch } from "undici";
2
+ const AUTH_FETCH_TIMEOUT_MS = 30_000;
2
3
  function extractCookiePairs(setCookies) {
3
4
  const pairs = [];
4
5
  for (const sc of setCookies) {
@@ -8,16 +9,38 @@ function extractCookiePairs(setCookies) {
8
9
  }
9
10
  return pairs.join("; ");
10
11
  }
12
+ /** Reject cookie values containing CR/LF to prevent header injection. */
13
+ function assertNoCRLF(value, label) {
14
+ if (/[\r\n]/.test(value)) {
15
+ throw new Error(`${label} contains illegal CR/LF characters`);
16
+ }
17
+ }
11
18
  export async function loginWithPassword(baseUrl, email, password) {
12
19
  const url = `${baseUrl.replace(/\/$/, "")}/api/auth/sign-in`;
13
- const res = await fetch(url, {
14
- method: "POST",
15
- headers: { "Content-Type": "application/json" },
16
- body: JSON.stringify({ email, password })
17
- });
20
+ const controller = new AbortController();
21
+ const timer = setTimeout(() => controller.abort(), AUTH_FETCH_TIMEOUT_MS);
22
+ let res;
23
+ try {
24
+ res = await fetch(url, {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify({ email, password }),
28
+ signal: controller.signal,
29
+ });
30
+ }
31
+ catch (err) {
32
+ if (err.name === "AbortError")
33
+ throw new Error(`Sign-in request timed out after ${AUTH_FETCH_TIMEOUT_MS / 1000}s`);
34
+ throw err;
35
+ }
36
+ finally {
37
+ clearTimeout(timer);
38
+ }
18
39
  if (!res.ok) {
19
- const text = await res.text().catch(() => "");
20
- throw new Error(`Sign-in failed: ${res.status} ${text}`);
40
+ const raw = await res.text().catch(() => "");
41
+ const sanitized = raw.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
42
+ const truncated = sanitized.length > 200 ? sanitized.slice(0, 200) + "..." : sanitized;
43
+ throw new Error(`Sign-in failed: ${res.status} ${truncated}`);
21
44
  }
22
45
  const anyHeaders = res.headers;
23
46
  let setCookies = [];
@@ -33,5 +56,6 @@ export async function loginWithPassword(baseUrl, email, password) {
33
56
  throw new Error("Sign-in succeeded but no Set-Cookie received");
34
57
  }
35
58
  const cookieHeader = extractCookiePairs(setCookies);
59
+ assertNoCRLF(cookieHeader, "Cookie header from sign-in");
36
60
  return { cookieHeader };
37
61
  }