typst-mcp 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +180 -2
- package/dist/client/auth.js +310 -0
- package/dist/client/compiler.js +283 -0
- package/dist/client/errors.js +18 -0
- package/dist/client/typst-client.js +545 -0
- package/dist/index.js +144 -0
- package/dist/protocol/graphql.js +165 -0
- package/dist/protocol/ws-codec.js +312 -0
- package/dist/protocol/yjs-doc.js +139 -0
- package/dist/tools/definitions.js +356 -0
- package/package.json +56 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dang Duy Toan (caphefalumi)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE(FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,181 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# Typst Web MCP
|
|
4
|
+
|
|
5
|
+
**Model Context Protocol (MCP) server for [`https://typst.app`](https://typst.app) — cloud projects, Yjs CRDT editing, and Typst compiler diagnostics**
|
|
6
|
+
|
|
7
|
+
Install from npm as [`typst-mcp`](https://www.npmjs.com/package/typst-mcp). Source repo: [`typst-web-mcp`](https://github.com/caphefalumi/typst-web-mcp).
|
|
8
|
+
|
|
9
|
+
[](https://www.npmjs.com/package/typst-mcp)
|
|
10
|
+
[](https://www.npmjs.com/package/typst-mcp)
|
|
11
|
+
[](./LICENSE)
|
|
12
|
+
[](https://github.com/caphefalumi/typst-web-mcp/actions/workflows/ci.yml)
|
|
13
|
+
[](https://github.com/caphefalumi/typst-web-mcp/actions/workflows/npm-publish.yml)
|
|
14
|
+
[](https://bun.sh)
|
|
15
|
+
[](https://www.typescriptlang.org/)
|
|
16
|
+
[](https://modelcontextprotocol.io)
|
|
17
|
+
[](./docs/PROTOCOL.md)
|
|
18
|
+
[-2ea44f?style=flat-square)](./test/typst-mcp.test.ts)
|
|
19
|
+
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Overview
|
|
25
|
+
|
|
26
|
+
`typst-web-mcp` connects AI coding and writing agents directly to collaborative [**typst.app**](https://typst.app) cloud projects. Instead of relying on brittle browser DOM scraping, it implements the native `typst.app` client protocol:
|
|
27
|
+
|
|
28
|
+
- **Native Yjs CRDT Text Sync:** Decodes and mutates multiplayer `Y.Doc` (`doc.getText("text")`) state vectors so surgical edits preserve collaborator cursors and version history.
|
|
29
|
+
- **Binary `/editing/v3` WebSocket Wire Protocol:** Encodes and decodes `lib0` variable-length binary frames (`Subscribe`, `TextUpdate`, `Pong`, `Refresh`, `Presence`, `Cursor`, `Leave`) with REST `/v1/files/:projectId/:fileId/yjs` persistence.
|
|
30
|
+
- **GraphQL & REST Project Management:** Full project CRUD, file tree inspection, rename/move, delete, and binary figure/asset uploads (`PNG`, `JPG`, `SVG`, `PDF`, fonts).
|
|
31
|
+
- **Direct Authentication + Fallback:** Authenticates via `GET /v1/to-signin` + `POST /v1/auth/signin` (`__Http-typst-session`), with persistent session caching and automatic cookie/Playwright fallback on challenge.
|
|
32
|
+
- **Compiler Diagnostics, Preview Screenshots & PDF Export:** Synchronizes cloud Yjs text and binary assets into an isolated workspace and runs the Typst compiler to return structured line/column diagnostics, high-DPI PNG preview screenshots (`typst_capture_screenshot`), and compiled PDFs (`typst_export_pdf`).
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Architecture
|
|
37
|
+
|
|
38
|
+
```mermaid
|
|
39
|
+
flowchart LR
|
|
40
|
+
Agent["AI Agent\n(Pi / Claude / Cursor)"] <-->|"JSON-RPC (stdio)"| MCP["typst-mcp\n(Node / TypeScript)"]
|
|
41
|
+
MCP <-->|"GraphQL (/v1/graphql)\n+ X-Typst-Preflight"| API["https://api.typst.app"]
|
|
42
|
+
MCP <-->|"REST Yjs & Blobs\n(/v1/files/:pid/:fid/yjs)"| API
|
|
43
|
+
MCP <-->|"Binary lib0 WebSocket\n(wss://api.typst.app/editing/v3)"| WS["typst.app Real-Time Sync"]
|
|
44
|
+
MCP -->|"Workspace Sync + Compile"| TypstCLI["Typst Compiler\n(Diagnostics & PDF)"]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
See [`docs/PROTOCOL.md`](./docs/PROTOCOL.md) for the complete reverse-engineered protocol specification.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## MCP Tools (16 Tools)
|
|
52
|
+
|
|
53
|
+
| Category | Tool Name | Description |
|
|
54
|
+
|:---|:---|:---|
|
|
55
|
+
| **Auth** | `typst_auth_login` | Authenticate with `https://typst.app` via email/password (`POST /v1/auth/signin`) or `__Http-typst-session` cookie, with browser fallback. |
|
|
56
|
+
| **Auth** | `typst_auth_status` | Check active authentication state, login method, and stored session cookies. |
|
|
57
|
+
| **Projects** | `typst_list_projects` | List all owned and shared/external projects in the user's `typst.app` dashboard (`DashboardContent`). |
|
|
58
|
+
| **Projects** | `typst_get_project` | Retrieve full project metadata, access permissions, pinned compiler version, and file tree (`ProjectFiles`). |
|
|
59
|
+
| **Projects** | `typst_create_project` | Create a new `typst.app` cloud project with initial `.typ`/`.bib` files and pinned compiler version. |
|
|
60
|
+
| **Projects** | `typst_delete_project` | Permanently delete a `typst.app` project by ID (`DeleteProjectMutation`). |
|
|
61
|
+
| **Files & CRDT** | `typst_list_files` | List all multiplayer Yjs text files and binary asset blobs in a project. |
|
|
62
|
+
| **Files & CRDT** | `typst_read_file` | Read any project file — decodes binary Yjs CRDT state (`doc.getText("text")`) into UTF-8 text or base64 for binary assets. |
|
|
63
|
+
| **Files & CRDT** | `typst_write_file` | Create or overwrite a `.typ`/`.bib`/`.toml` file, syncing Yjs state diffs over `/editing/v3` WebSocket and REST. |
|
|
64
|
+
| **Files & CRDT** | `typst_edit_file` | Apply non-overlapping surgical `oldText -> newText` replacements directly to the `Y.Text("text")` CRDT. |
|
|
65
|
+
| **Assets & Tree** | `typst_upload_asset` | Upload or replace binary assets (`PNG`, `JPG`, `SVG`, `PDF`, fonts) from a local path or base64 payload. |
|
|
66
|
+
| **Assets & Tree** | `typst_rename_file` | Rename or move a file inside a `typst.app` project (`UpdatesFileMutation`). |
|
|
67
|
+
| **Assets & Tree** | `typst_delete_file` | Delete a file or binary asset from a project (`DeleteFilesMutation`). |
|
|
68
|
+
| **Compiler** | `typst_get_diagnostics` | Sync all cloud files and run `typst compile` to return structured errors, warnings, line/column spans, and hints. |
|
|
69
|
+
| **Compiler** | `typst_export_pdf` | Sync all cloud text & binary assets, compile the project to PDF, and save to `outputPath`. |
|
|
70
|
+
| **Preview** | `typst_capture_screenshot` | Capture high-DPI PNG preview screenshots (`mode="pages"` renders compiled PNG pages; `mode="browser"` screenshots the live `typst.app` web UI). |
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Quick Start
|
|
75
|
+
|
|
76
|
+
### Prerequisites
|
|
77
|
+
|
|
78
|
+
- [**Node.js**](https://nodejs.org/) `>= 18` (for `npx` install) or [**Bun**](https://bun.sh) `>= 1.2` (for local development)
|
|
79
|
+
- [**Typst CLI**](https://github.com/typst/typst) (`typst` on `PATH` for `typst_get_diagnostics` and `typst_export_pdf`)
|
|
80
|
+
|
|
81
|
+
### Installation
|
|
82
|
+
|
|
83
|
+
**Recommended — no clone, no global install:**
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npx -y typst-mcp
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Runs the published [`typst-mcp`](https://www.npmjs.com/package/typst-mcp) package (Node `>= 18`). Use the same command in MCP config below. `-y` skips the npx install prompt.
|
|
90
|
+
|
|
91
|
+
**From source (development):**
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
git clone https://github.com/caphefalumi/typst-web-mcp.git
|
|
95
|
+
cd typst-web-mcp
|
|
96
|
+
bun install
|
|
97
|
+
bun run dev
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### MCP Client Configuration
|
|
101
|
+
|
|
102
|
+
Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`, `claude_desktop_config.json`, or `.cursor/mcp.json`):
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"mcpServers": {
|
|
107
|
+
"typst-mcp": {
|
|
108
|
+
"command": "npx",
|
|
109
|
+
"args": ["-y", "typst-mcp"],
|
|
110
|
+
"env": {
|
|
111
|
+
"TYPST_EMAIL": "your-email@example.com",
|
|
112
|
+
"TYPST_PASSWORD": "your-password"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
> **Session Persistence:** Once authenticated via `typst_auth_login` (or environment variables), the `__Http-typst-session` cookie is cached locally at `~/.pi/agent/typst-mcp-session.json` so subsequent tool calls authenticate automatically without re-signing in.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Development & Verification
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# Compile publishable output to dist/
|
|
127
|
+
bun run build
|
|
128
|
+
|
|
129
|
+
# Typecheck with strict TypeScript
|
|
130
|
+
bun run typecheck
|
|
131
|
+
|
|
132
|
+
# Full suite (includes test/smoke.test.ts: dist bin + npx -y typst-mcp)
|
|
133
|
+
bun test
|
|
134
|
+
|
|
135
|
+
# Smoke only (same as pi npx MCP config)
|
|
136
|
+
bun run test:smoke
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Push/PR to `main` runs [`.github/workflows/ci.yml`](./.github/workflows/ci.yml) (`bun ci`, build, typecheck, `bun test`).
|
|
140
|
+
|
|
141
|
+
### Publishing to npm
|
|
142
|
+
|
|
143
|
+
Same flow as [Canvas-CLI](https://github.com/caphefalumi/Canvas-CLI):
|
|
144
|
+
|
|
145
|
+
1. Bump `version` in `package.json` (must not match a version already on npm).
|
|
146
|
+
2. Commit, push, and create a GitHub **Release** for that tag — or run **Actions → Publish → Run workflow** (`workflow_dispatch`).
|
|
147
|
+
|
|
148
|
+
[`.github/workflows/npm-publish.yml`](./.github/workflows/npm-publish.yml) runs the same checks as CI (`bun test`, including smoke) in a `test` job; the `publish` job runs only if that passes, on the `npm-publish` environment: `bun ci`, `bun run build`, then `npm publish --access public` via [npm trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC). The publish step clears `NODE_AUTH_TOKEN` so the CLI uses GitHub OIDC instead of a stale token from `setup-node`.
|
|
149
|
+
|
|
150
|
+
**Trusted publisher (maintainers):** package [`typst-mcp`](https://www.npmjs.com/package/typst-mcp) → Settings → Trusted publishing → GitHub Actions — user/org `caphefalumi`, repo `typst-web-mcp`, workflow file `npm-publish.yml`, environment `npm-publish`, allow **npm publish**.
|
|
151
|
+
|
|
152
|
+
### Project Structure
|
|
153
|
+
|
|
154
|
+
```text
|
|
155
|
+
typst-web-mcp/
|
|
156
|
+
├── docs/
|
|
157
|
+
│ └── PROTOCOL.md # Reverse-engineered typst.app REST, GraphQL, Yjs & WS v3 spec
|
|
158
|
+
├── src/
|
|
159
|
+
│ ├── protocol/
|
|
160
|
+
│ │ ├── ws-codec.ts # Binary lib0 /editing/v3 WebSocket encoder & decoder
|
|
161
|
+
│ │ ├── yjs-doc.ts # Yjs CRDT text decoding, replacement & surgical patching
|
|
162
|
+
│ │ └── graphql.ts # GraphQL queries, mutations, fragments & types
|
|
163
|
+
│ ├── client/
|
|
164
|
+
│ │ ├── auth.ts # Direct /v1/auth/signin login, cookie jar & Playwright fallback
|
|
165
|
+
│ │ ├── compiler.ts # Typst binary resolver, diagnostic parser, PDF & PNG preview compiler
|
|
166
|
+
│ │ ├── errors.ts # Structured TypstLoadError & error formatting helpers
|
|
167
|
+
│ │ └── typst-client.ts # TypstCloudClient (REST, GraphQL, WebSocket v3, Compiler facade)
|
|
168
|
+
│ ├── tools/
|
|
169
|
+
│ │ └── definitions.ts # MCP JSON Schema definitions for all 16 tools
|
|
170
|
+
│ └── index.ts # MCP stdio server entrypoint (dev); npm bin runs dist/index.js
|
|
171
|
+
├── test/
|
|
172
|
+
│ └── typst-mcp.test.ts # 7 test suites (57 assertions) covering codecs, CRDTs & E2E
|
|
173
|
+
├── LICENSE # MIT License
|
|
174
|
+
└── package.json
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
Released under the [MIT License](./LICENSE). Copyright (c) 2026 Dang Duy Toan (`caphefalumi`).
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
const DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36";
|
|
5
|
+
function extractRawSetCookies(headers) {
|
|
6
|
+
const headerWithGetSetCookie = headers;
|
|
7
|
+
if (typeof headerWithGetSetCookie.getSetCookie === "function") {
|
|
8
|
+
return headerWithGetSetCookie.getSetCookie();
|
|
9
|
+
}
|
|
10
|
+
const singleCookie = headers.get("set-cookie");
|
|
11
|
+
if (singleCookie) {
|
|
12
|
+
return [singleCookie];
|
|
13
|
+
}
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
function resolveSessionFilePath(configuredPath) {
|
|
17
|
+
if (configuredPath === undefined) {
|
|
18
|
+
return join(homedir(), ".pi", "agent", "typst-mcp-session.json");
|
|
19
|
+
}
|
|
20
|
+
return configuredPath;
|
|
21
|
+
}
|
|
22
|
+
export function parseSetCookieHeaders(headers) {
|
|
23
|
+
const result = {};
|
|
24
|
+
const rawList = extractRawSetCookies(headers);
|
|
25
|
+
for (const raw of rawList) {
|
|
26
|
+
const firstPart = raw.split(";")[0]?.trim();
|
|
27
|
+
if (!firstPart) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const eqIdx = firstPart.indexOf("=");
|
|
31
|
+
if (eqIdx <= 0) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const name = firstPart.slice(0, eqIdx).trim();
|
|
35
|
+
const val = firstPart.slice(eqIdx + 1).trim();
|
|
36
|
+
if (val === "") {
|
|
37
|
+
delete result[name];
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
result[name] = val;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
export function parseCookieString(cookieStr) {
|
|
46
|
+
const trimmed = cookieStr.trim();
|
|
47
|
+
if (!trimmed) {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
if (!trimmed.includes("=")) {
|
|
51
|
+
return { "__Http-typst-session": trimmed };
|
|
52
|
+
}
|
|
53
|
+
const map = {};
|
|
54
|
+
for (const part of trimmed.split(";")) {
|
|
55
|
+
const p = part.trim();
|
|
56
|
+
if (!p) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const eq = p.indexOf("=");
|
|
60
|
+
if (eq <= 0) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const k = p.slice(0, eq).trim();
|
|
64
|
+
const v = p.slice(eq + 1).trim();
|
|
65
|
+
if (k && v) {
|
|
66
|
+
map[k] = v;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return map;
|
|
70
|
+
}
|
|
71
|
+
export class TypstAuthManager {
|
|
72
|
+
appUrl;
|
|
73
|
+
apiUrl;
|
|
74
|
+
sessionFilePath;
|
|
75
|
+
fetchImpl;
|
|
76
|
+
browserFallbackFn;
|
|
77
|
+
cookies = {};
|
|
78
|
+
authMethod = null;
|
|
79
|
+
email;
|
|
80
|
+
constructor(options = {}) {
|
|
81
|
+
const rawAppUrl = options.appUrl ?? process.env.TYPST_APP_URL ?? "https://typst.app";
|
|
82
|
+
const rawApiUrl = options.apiUrl ?? process.env.TYPST_API_URL ?? "https://api.typst.app";
|
|
83
|
+
this.appUrl = rawAppUrl.replace(/\/+$/, "");
|
|
84
|
+
this.apiUrl = rawApiUrl.replace(/\/+$/, "");
|
|
85
|
+
this.sessionFilePath = resolveSessionFilePath(options.sessionFilePath);
|
|
86
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
87
|
+
this.browserFallbackFn = options.browserFallbackFn;
|
|
88
|
+
this.loadStoredSession();
|
|
89
|
+
if (Object.keys(this.cookies).length === 0 &&
|
|
90
|
+
process.env.TYPST_SESSION_COOKIE) {
|
|
91
|
+
this.setSessionCookie(process.env.TYPST_SESSION_COOKIE);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
loadStoredSession() {
|
|
95
|
+
if (!this.sessionFilePath || !existsSync(this.sessionFilePath)) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const raw = JSON.parse(readFileSync(this.sessionFilePath, "utf8"));
|
|
100
|
+
if (raw && typeof raw.cookies === "object" && raw.cookies) {
|
|
101
|
+
this.cookies = { ...raw.cookies };
|
|
102
|
+
this.authMethod = raw.authMethod ?? "cookie";
|
|
103
|
+
this.email = raw.email;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Ignore corrupted session file
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
saveStoredSession() {
|
|
111
|
+
if (!this.sessionFilePath) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
mkdirSync(dirname(this.sessionFilePath), { recursive: true });
|
|
116
|
+
const payload = {
|
|
117
|
+
cookies: this.cookies,
|
|
118
|
+
email: this.email,
|
|
119
|
+
authMethod: this.authMethod ?? "cookie",
|
|
120
|
+
updatedAt: new Date().toISOString(),
|
|
121
|
+
};
|
|
122
|
+
writeFileSync(this.sessionFilePath, JSON.stringify(payload, null, 2), "utf8");
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Non-fatal if filesystem is read-only
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
setSessionCookie(cookieInput, email) {
|
|
129
|
+
const parsed = parseCookieString(cookieInput);
|
|
130
|
+
this.cookies = { ...this.cookies, ...parsed };
|
|
131
|
+
this.authMethod = "cookie";
|
|
132
|
+
if (email) {
|
|
133
|
+
this.email = email;
|
|
134
|
+
}
|
|
135
|
+
this.saveStoredSession();
|
|
136
|
+
}
|
|
137
|
+
getCookieHeader() {
|
|
138
|
+
return Object.entries(this.cookies)
|
|
139
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
140
|
+
.join("; ");
|
|
141
|
+
}
|
|
142
|
+
hasSession() {
|
|
143
|
+
return Boolean(this.cookies["__Http-typst-session"] ||
|
|
144
|
+
this.cookies["typst-session"] ||
|
|
145
|
+
this.cookies["session"]);
|
|
146
|
+
}
|
|
147
|
+
getStatus() {
|
|
148
|
+
return {
|
|
149
|
+
authenticated: this.hasSession(),
|
|
150
|
+
authMethod: this.authMethod,
|
|
151
|
+
email: this.email,
|
|
152
|
+
cookieNames: Object.keys(this.cookies),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
async login(options = {}) {
|
|
156
|
+
const sessionCookie = options.sessionCookie ?? process.env.TYPST_SESSION_COOKIE;
|
|
157
|
+
const email = options.email ?? process.env.TYPST_EMAIL;
|
|
158
|
+
const password = options.password ?? process.env.TYPST_PASSWORD;
|
|
159
|
+
if (sessionCookie && (!email || !password)) {
|
|
160
|
+
this.setSessionCookie(sessionCookie, email);
|
|
161
|
+
return {
|
|
162
|
+
authenticated: this.hasSession(),
|
|
163
|
+
authMethod: "cookie",
|
|
164
|
+
email: this.email,
|
|
165
|
+
cookieNames: Object.keys(this.cookies),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (!email || !password) {
|
|
169
|
+
if (sessionCookie) {
|
|
170
|
+
this.setSessionCookie(sessionCookie, email);
|
|
171
|
+
return {
|
|
172
|
+
authenticated: this.hasSession(),
|
|
173
|
+
authMethod: "cookie",
|
|
174
|
+
email: this.email,
|
|
175
|
+
cookieNames: Object.keys(this.cookies),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
throw new Error("Missing credentials: provide email & password (or TYPST_EMAIL / TYPST_PASSWORD), or sessionCookie (TYPST_SESSION_COOKIE).");
|
|
179
|
+
}
|
|
180
|
+
// Step 1: Initialize pre-auth session cookie via GET /v1/to-signin
|
|
181
|
+
const initRes = await this.fetchImpl(`${this.apiUrl}/v1/to-signin`, {
|
|
182
|
+
method: "GET",
|
|
183
|
+
redirect: "manual",
|
|
184
|
+
headers: {
|
|
185
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
const preAuthCookies = parseSetCookieHeaders(initRes.headers);
|
|
189
|
+
const mergedCookies = {
|
|
190
|
+
...this.cookies,
|
|
191
|
+
...preAuthCookies,
|
|
192
|
+
};
|
|
193
|
+
const cookieHeader = Object.entries(mergedCookies)
|
|
194
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
195
|
+
.join("; ");
|
|
196
|
+
// Step 2: Submit credentials to POST /v1/auth/signin
|
|
197
|
+
const form = new URLSearchParams();
|
|
198
|
+
form.set("email", email);
|
|
199
|
+
form.set("password", password);
|
|
200
|
+
form.set("redirect", "");
|
|
201
|
+
form.set("grant-id", "");
|
|
202
|
+
form.set("captcha-trap", ""); // Honeypot field must remain empty
|
|
203
|
+
const signinRes = await this.fetchImpl(`${this.apiUrl}/v1/auth/signin`, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
redirect: "manual",
|
|
206
|
+
headers: {
|
|
207
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
208
|
+
Cookie: cookieHeader,
|
|
209
|
+
Origin: this.appUrl,
|
|
210
|
+
Referer: `${this.appUrl}/signin/`,
|
|
211
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
212
|
+
},
|
|
213
|
+
body: form.toString(),
|
|
214
|
+
});
|
|
215
|
+
const postAuthCookies = parseSetCookieHeaders(signinRes.headers);
|
|
216
|
+
Object.assign(mergedCookies, postAuthCookies);
|
|
217
|
+
const location = signinRes.headers.get("location") ?? "";
|
|
218
|
+
const isChallengeOrBlocked = signinRes.status === 403 ||
|
|
219
|
+
signinRes.status === 429 ||
|
|
220
|
+
location.includes("captcha") ||
|
|
221
|
+
location.includes("challenge");
|
|
222
|
+
const isInvalidCredentials = signinRes.status === 401 ||
|
|
223
|
+
location.includes("error=") ||
|
|
224
|
+
location.includes("invalid");
|
|
225
|
+
if (!isChallengeOrBlocked &&
|
|
226
|
+
!isInvalidCredentials &&
|
|
227
|
+
(signinRes.status === 200 ||
|
|
228
|
+
signinRes.status === 302 ||
|
|
229
|
+
signinRes.status === 303) &&
|
|
230
|
+
(mergedCookies["__Http-typst-session"] || mergedCookies["typst-session"])) {
|
|
231
|
+
this.cookies = mergedCookies;
|
|
232
|
+
this.authMethod = "api-password";
|
|
233
|
+
this.email = email;
|
|
234
|
+
this.saveStoredSession();
|
|
235
|
+
return {
|
|
236
|
+
authenticated: true,
|
|
237
|
+
authMethod: "api-password",
|
|
238
|
+
email,
|
|
239
|
+
cookieNames: Object.keys(this.cookies),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
// Step 3: Automatic fallback to cookie or browser session if Turnstile/CAPTCHA challenge occurs
|
|
243
|
+
if (options.useBrowserFallback !== false &&
|
|
244
|
+
(isChallengeOrBlocked || !this.hasSession())) {
|
|
245
|
+
if (sessionCookie) {
|
|
246
|
+
this.setSessionCookie(sessionCookie, email);
|
|
247
|
+
return {
|
|
248
|
+
authenticated: this.hasSession(),
|
|
249
|
+
authMethod: "cookie",
|
|
250
|
+
email,
|
|
251
|
+
cookieNames: Object.keys(this.cookies),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const fallbackRunner = this.browserFallbackFn ?? defaultPlaywrightBrowserFallback;
|
|
255
|
+
if (fallbackRunner) {
|
|
256
|
+
const fallbackCookies = await fallbackRunner(this.appUrl, this.apiUrl, email, password);
|
|
257
|
+
this.cookies = { ...mergedCookies, ...fallbackCookies };
|
|
258
|
+
this.authMethod = "browser-fallback";
|
|
259
|
+
this.email = email;
|
|
260
|
+
this.saveStoredSession();
|
|
261
|
+
return {
|
|
262
|
+
authenticated: this.hasSession(),
|
|
263
|
+
authMethod: "browser-fallback",
|
|
264
|
+
email,
|
|
265
|
+
cookieNames: Object.keys(this.cookies),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
let redirectDetail = "";
|
|
270
|
+
if (location) {
|
|
271
|
+
redirectDetail = `, redirect: ${location}`;
|
|
272
|
+
}
|
|
273
|
+
throw new Error(`Sign-in failed (HTTP ${signinRes.status}${redirectDetail}). Verify TYPST_EMAIL/TYPST_PASSWORD or supply TYPST_SESSION_COOKIE.`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export const defaultPlaywrightBrowserFallback = async (appUrl, _apiUrl, email, password) => {
|
|
277
|
+
const { spawnSync } = await import("node:child_process");
|
|
278
|
+
const pyScript = `
|
|
279
|
+
import json, os
|
|
280
|
+
from playwright.sync_api import sync_playwright
|
|
281
|
+
|
|
282
|
+
with sync_playwright() as p:
|
|
283
|
+
browser = p.chromium.launch(channel="msedge", headless=True)
|
|
284
|
+
ctx = browser.new_context()
|
|
285
|
+
page = ctx.new_page()
|
|
286
|
+
page.goto(os.environ["TYPST_FB_URL"] + "/signin/", wait_until="networkidle")
|
|
287
|
+
page.fill("input[name='email']", os.environ["TYPST_FB_EMAIL"])
|
|
288
|
+
page.fill("input[name='password']", os.environ["TYPST_FB_PASS"])
|
|
289
|
+
page.click("input[type='submit']")
|
|
290
|
+
page.wait_for_load_state("networkidle")
|
|
291
|
+
cookies = {c["name"]: c["value"] for c in ctx.cookies()}
|
|
292
|
+
print(json.dumps(cookies))
|
|
293
|
+
browser.close()
|
|
294
|
+
`;
|
|
295
|
+
const proc = spawnSync("python", ["-c", pyScript], {
|
|
296
|
+
env: {
|
|
297
|
+
...process.env,
|
|
298
|
+
TYPST_FB_URL: appUrl,
|
|
299
|
+
TYPST_FB_EMAIL: email,
|
|
300
|
+
TYPST_FB_PASS: password,
|
|
301
|
+
},
|
|
302
|
+
encoding: "utf8",
|
|
303
|
+
timeout: 30000,
|
|
304
|
+
});
|
|
305
|
+
if (proc.status !== 0 || !proc.stdout.trim()) {
|
|
306
|
+
throw new Error(`Playwright browser fallback failed: ${proc.stderr || "no output"}`);
|
|
307
|
+
}
|
|
308
|
+
const lines = proc.stdout.trim().split(/\r\n|\n/);
|
|
309
|
+
return JSON.parse(lines[lines.length - 1]);
|
|
310
|
+
};
|