dsh-notion-oauth 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ First stable release.
6
+
7
+ - OAuth 2.0 authorization code flow with PKCE (S256) and a verified `state`,
8
+ against the official Notion MCP server — no integration token to copy.
9
+ - GUI sign-in: a **Notion** page under Settings with Login / Logout and a live
10
+ connection status.
11
+ - CLI fallback: `dsh notion login`, `dsh notion logout`, `dsh notion status`.
12
+ - Silent token refresh five minutes before expiry, retrying every minute while a
13
+ refresh keeps failing.
14
+ - Notion tools exposed as `mcp__notion__*` through the official MCP server.
15
+ - Localized display metadata (`locale/en.json`, `locale/zh.json`) and a vector icon.
16
+ - Hardened local control routes: loopback-only, method-pinned, cross-site
17
+ refused, and JSON-only for state changes. The token never reaches the browser
18
+ half, and it is never logged.
package/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-notion-oauth contributors
4
+
5
+ This plugin reuses code and design patterns from:
6
+
7
+ - dsh-notion-mcp (https://github.com/zengming21/dsh-notion-mcp) — MIT
8
+ Copyright (c) 2026 mingzeng
9
+ - dsh-notion-connector (https://github.com/zhengjy01/dsh-notion-connector) — MIT
10
+ Copyright (c) 2026 zhengjunyao
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # dsh-notion-oauth
2
+
3
+ Notion integration for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) via the official Notion MCP server with OAuth 2.0 (authorization code + PKCE).
4
+
5
+ ## Features
6
+
7
+ - **GUI login** — a "Notion" page under Settings with a one-click Login (no terminal, no Integration Token).
8
+ - **CLI fallback** — `dsh notion login` for headless / CLI profiles.
9
+ - **OAuth + PKCE** — dynamic client registration (RFC 7591), no `client_id`/secret to copy.
10
+ - **Silent refresh** — access token refreshed automatically; refresh token rotated atomically.
11
+ - **Tools as `mcp__notion__*`** — search, read/create pages, databases, comments via the official MCP server.
12
+
13
+ ## Install
14
+
15
+ Via the DSH GUI: Settings → Plugins → Add plugin → `dsh-notion-oauth`.
16
+
17
+ Or via CLI:
18
+
19
+ ```sh
20
+ dsh plugin --profile <name> add dsh-notion-oauth
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ 1. Open Settings → Notion → Login.
26
+ 2. Approve access in the browser.
27
+ 3. The token is stored in DSH credentials; the `mcp__notion__*` tools become available.
28
+
29
+ ## Config
30
+
31
+ | Key | Default | Meaning |
32
+ |---|---|---|
33
+ | `mcpUrl` | `https://mcp.notion.com/mcp` | Notion MCP server URL |
34
+ | `port` | `53007` | Local OAuth callback port (`127.0.0.1`) |
35
+ | `refreshLeadMs` | `300000` (5 min) | Refresh the token this far before it expires |
36
+ | `refreshRetryMs` | `60000` (1 min) | Retry interval when a refresh attempt fails |
37
+
38
+ ## Security
39
+
40
+ - **Loopback-only control routes.** The Settings page talks to
41
+ `/api/dsh-notion-oauth/{status,login,logout}`. Each route is pinned to one HTTP
42
+ method (`GET /status`, `POST /login`, `POST /logout`) and accepts only requests
43
+ whose remote address and `Host` are loopback and that are not `Sec-Fetch-Site:
44
+ cross-site`. When an `Origin` is present it must be the app itself
45
+ (`dsh-app://app`) or the same host. The Desktop shell proxies renderer calls and
46
+ strips `Origin`/`Sec-Fetch-Site`, so an absent `Origin` is normal; the two POST
47
+ routes therefore additionally require `Content-Type: application/json` — not a
48
+ CORS-simple value, so a cross-site caller first needs a preflight that these
49
+ routes reject.
50
+ - **OAuth hardening.** Authorization code + PKCE (S256), a per-flow `state` the
51
+ callback server verifies, and a 10-minute deadline on the callback. A malformed
52
+ or forged callback is answered with 400 and leaves the pending login running,
53
+ so a stray local request cannot kill a real authorization.
54
+ - **Transport.** `mcpUrl` must be `https://` — the plugin refuses to load
55
+ otherwise. Discovery endpoints (authorization, token, registration) are read
56
+ from whatever the `mcpUrl` resource advertises, so keep it on a trusted origin.
57
+ - **Token at rest.** The access/refresh token is stored as a single record in the
58
+ DSH credentials service (`NOTION_OAUTH`). That store is only as private as your
59
+ OS user account: tool processes run as the same user, so any plugin with
60
+ credentials access can read every stored secret (`NOTION_OAUTH`,
61
+ `DEEPSEEK_API_KEY`, …), not just its own. The token is never exposed to the
62
+ browser half, never returned by an HTTP route, and never written to logs.
63
+
64
+ ## Development
65
+
66
+ ```sh
67
+ pnpm install # installs dev dependencies and builds lib/ via the prepare hook
68
+ pnpm run build # rebuilds lib/index.js (host) and lib/client.js (browser half)
69
+ ```
70
+
71
+ `lib/` is build output and is not committed; `pnpm install` and `pnpm pack`
72
+ regenerate it through the `prepare` script.
73
+
74
+ `pnpm run typecheck` checks the sources against the DSH host packages
75
+ (`@deepseek-ai/*`). DeepSeek Harness provides those at runtime, and their npm
76
+ publication is currently incomplete — some transitive packages are missing from
77
+ the registry — so typechecking needs an environment that supplies them. The
78
+ build itself has no such dependency.
79
+
80
+ ## License
81
+
82
+ MIT. Reuses code/patterns from `dsh-notion-mcp` and `dsh-notion-connector` (both MIT).
package/README.zh.md ADDED
@@ -0,0 +1,47 @@
1
+ # dsh-notion-oauth
2
+
3
+ 通过官方 Notion MCP 服务器,用 OAuth 2.0(授权码 + PKCE)把 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 连接到 [Notion](https://www.notion.com)。
4
+
5
+ ## 特性
6
+
7
+ - **GUI 一键登录** —— 设置页中的「Notion」页面提供一键 Login,无需终端、无需 Integration Token。
8
+ - **CLI 备选** —— `dsh notion login` 适用于 headless / CLI 配置。
9
+ - **OAuth + PKCE** —— 动态客户端注册(RFC 7591),无需复制 `client_id` 或密钥。
10
+ - **静默刷新** —— access token 自动刷新,refresh token 原子轮换。
11
+ - **工具以 `mcp__notion__*` 挂载** —— 通过官方 MCP 服务器搜索、读取/创建页面、数据库与评论。
12
+
13
+ ## 安装
14
+
15
+ 通过 DSH GUI:设置 → 插件 → 添加插件 → `dsh-notion-oauth`。
16
+
17
+ 或通过 CLI:
18
+
19
+ ```sh
20
+ dsh plugin --profile <name> add dsh-notion-oauth
21
+ ```
22
+
23
+ ## 使用
24
+
25
+ 1. 打开 设置 → Notion → Login。
26
+ 2. 在浏览器中批准访问。
27
+ 3. Token 存入 DSH 凭据层,`mcp__notion__*` 工具随即可用。
28
+
29
+ ## 配置
30
+
31
+ | 键 | 默认值 | 说明 |
32
+ |---|---|---|
33
+ | `mcpUrl` | `https://mcp.notion.com/mcp` | Notion MCP 服务器 URL |
34
+ | `port` | `53007` | 本地 OAuth 回调端口(`127.0.0.1`) |
35
+ | `refreshLeadMs` | `300000`(5 分钟) | 在令牌到期前提前这么久刷新 |
36
+ | `refreshRetryMs` | `60000`(1 分钟) | 刷新失败时的重试间隔 |
37
+
38
+ ## 安全
39
+
40
+ - **仅限回环的控制路由。** 设置页通过 `/api/dsh-notion-oauth/{status,login,logout}` 通信。每个路由固定一种 HTTP 方法(`GET /status`、`POST /login`、`POST /logout`),并且只接受远端地址与 `Host` 均为回环、且非 `Sec-Fetch-Site: cross-site` 的请求。当请求携带 `Origin` 时,它必须是应用自身(`dsh-app://app`)或同源主机。Desktop 外壳会代理渲染进程的请求并**剥离** `Origin` 与 `Sec-Fetch-Site`,因此 `Origin` 缺失属于正常情况;两个 POST 路由因此额外要求 `Content-Type: application/json` —— 它不属于 CORS 简单值,跨站调用方必须先通过被本路由拒绝的预检请求。
41
+ - **OAuth 加固。** 授权码 + PKCE(S256)、每次流程独立的 `state`(由回调服务器校验),以及 10 分钟的回调截止时间。格式错误或伪造的回调返回 400,**不会**中断正在等待的登录。
42
+ - **传输。** `mcpUrl` 必须是 `https://`,否则插件拒绝加载。发现端点(授权、令牌、注册)取自 `mcpUrl` 资源所声明的地址,因此请保持其来源可信。
43
+ - **静态令牌。** access/refresh token 以单条记录存放在 DSH 凭据层(`NOTION_OAUTH`)。该存储的私密性仅等同于你的操作系统用户账户:工具进程以同一用户运行,因此任何拥有凭据访问权的插件都能读取**所有**已存密钥(`NOTION_OAUTH`、`DEEPSEEK_API_KEY` 等),而不只是自己的。令牌不会暴露给浏览器端、不会由任何 HTTP 路由返回,也不会写入日志。
44
+
45
+ ## 许可证
46
+
47
+ MIT。复用了 `dsh-notion-mcp` 与 `dsh-notion-connector`(均为 MIT)的代码与设计模式。
@@ -0,0 +1,9 @@
1
+ # dsh-notion-oauth bundle patch: inserts the dual-face plugin row into the
2
+ # profile roster. Applied as a profile bundle layer (the `dsh.bundle.patch`
3
+ # manifest field). Install with:
4
+ # dsh plugin --profile <name> add <package>
5
+ # The node half (exports ".") runs in the host process (OAuth, /api/dsh-notion-oauth
6
+ # routes, MCP mount); the browser half (exports "./client") renders the settings page.
7
+ - insert:
8
+ - id: notion
9
+ name: 'dsh-notion-oauth'
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="Notion">
2
+ <title>Notion</title>
3
+ <path d="M6.08 22.98 L6.08 7.23 L10.06 7.23 L15.74 18.46 L15.74 7.23 L19.73 7.23 L19.73 22.98 L15.74 22.98 L10.06 11.74 L10.06 22.98 Z M6.08 22.98 L6.08 7.23 L7.98 7.93 L7.98 23.68 Z M6.08 7.23 L10.06 7.23 L11.96 7.93 L7.98 7.93 Z M11.96 7.93 L17.64 19.16 L15.74 18.46 L10.06 7.23 Z M15.74 18.46 L15.74 7.23 L17.64 7.93 L17.64 19.16 Z M15.74 7.23 L19.73 7.23 L21.62 7.93 L17.64 7.93 Z M21.62 7.93 L21.62 23.68 L19.73 22.98 L19.73 7.23 Z M21.62 23.68 L17.64 23.68 L15.74 22.98 L19.73 22.98 Z M15.74 22.98 L10.06 11.74 L11.96 12.44 L17.64 23.68 Z M11.96 12.44 L11.96 23.68 L10.06 22.98 L10.06 11.74 Z M11.96 23.68 L7.98 23.68 L6.08 22.98 L10.06 22.98 Z" fill="#D0D4DA"/>
4
+ <path d="M7.98 23.68 L7.98 7.93 L11.96 7.93 L17.64 19.16 L17.64 7.93 L21.62 7.93 L21.62 23.68 L17.64 23.68 L11.96 12.44 L11.96 23.68 Z" fill="#111111"/>
5
+ <circle cx="24.9" cy="24.9" r="4.4" fill="#16A34A" stroke="#FFFFFF" stroke-width="1.5"/>
6
+ </svg>
package/lib/client.js ADDED
@@ -0,0 +1,185 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-notion-oauth",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ "use strict";
8
+ var __defProp = Object.defineProperty;
9
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
10
+ var __getOwnPropNames = Object.getOwnPropertyNames;
11
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
12
+ var __export = (target, all) => {
13
+ for (var name in all)
14
+ __defProp(target, name, { get: all[name], enumerable: true });
15
+ };
16
+ var __copyProps = (to, from, except, desc) => {
17
+ if (from && typeof from === "object" || typeof from === "function") {
18
+ for (let key of __getOwnPropNames(from))
19
+ if (!__hasOwnProp.call(to, key) && key !== except)
20
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
21
+ }
22
+ return to;
23
+ };
24
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
25
+
26
+ // src/client/index.ts
27
+ var index_exports = {};
28
+ __export(index_exports, {
29
+ apply: () => apply,
30
+ inject: () => inject
31
+ });
32
+ module.exports = __toCommonJS(index_exports);
33
+ var import_react2 = require("react");
34
+
35
+ // src/client/NotionSettings.tsx
36
+ var import_react = require("react");
37
+ var import_jsx_runtime = require("react/jsx-runtime");
38
+ var API = "/api/dsh-notion-oauth";
39
+ var POLL_MS = 4e3;
40
+ async function api(path, body) {
41
+ const response = await fetch(API + path, {
42
+ method: body === void 0 ? "GET" : "POST",
43
+ headers: body === void 0 ? void 0 : { "Content-Type": "application/json" },
44
+ body: body === void 0 ? void 0 : JSON.stringify(body),
45
+ signal: AbortSignal.timeout(15e3)
46
+ });
47
+ if (!response.ok) {
48
+ let detail = "";
49
+ try {
50
+ const j = await response.json();
51
+ if (typeof j?.error === "string") detail = j.error;
52
+ } catch {
53
+ }
54
+ throw new Error(detail ? `${detail} (HTTP ${response.status})` : `HTTP ${response.status}`);
55
+ }
56
+ return await response.json();
57
+ }
58
+ var styles = {
59
+ page: { display: "flex", flexDirection: "column", gap: 12, fontSize: 13, lineHeight: 1.5, padding: "4px 2px" },
60
+ title: { margin: 0, fontSize: 15 },
61
+ status: { display: "flex", alignItems: "center", gap: 9, padding: "9px 12px", borderRadius: 8, border: "1px solid transparent", fontWeight: 500 },
62
+ statusPending: { background: "rgba(127,127,127,.10)", borderColor: "rgba(127,127,127,.20)", opacity: 0.72 },
63
+ statusOn: { background: "rgba(34,197,94,.10)", borderColor: "rgba(34,197,94,.34)", color: "#16a34a" },
64
+ statusOff: { background: "rgba(229,72,77,.09)", borderColor: "rgba(229,72,77,.26)", color: "#e5484d" },
65
+ glyph: { width: 20, height: 20, borderRadius: "50%", display: "grid", placeItems: "center", fontSize: 12, lineHeight: 1, fontWeight: 700, flex: "0 0 auto" },
66
+ glyphPending: { background: "rgba(127,127,127,.18)" },
67
+ glyphOn: { background: "rgba(34,197,94,.18)", color: "#16a34a" },
68
+ glyphOff: { background: "rgba(229,72,77,.14)", color: "#e5484d" },
69
+ row: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" },
70
+ button: { padding: "5px 12px", borderRadius: 6, border: "1px solid rgba(127,127,127,.35)", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer" },
71
+ buttonPrimary: { padding: "5px 12px", borderRadius: 6, border: "1px solid #2563eb", background: "#2563eb", color: "#fff", font: "inherit", cursor: "pointer" },
72
+ buttonDisabled: { opacity: 0.5, cursor: "default" },
73
+ ok: { color: "#22c55e" },
74
+ err: { color: "#ef4444" },
75
+ info: { opacity: 0.72 },
76
+ hint: { opacity: 0.75, fontSize: 12 },
77
+ hintP: { margin: "4px 0" }
78
+ };
79
+ function NotionSettings() {
80
+ const [status, setStatus] = (0, import_react.useState)(null);
81
+ const [busy, setBusy] = (0, import_react.useState)(false);
82
+ const [note, setNote] = (0, import_react.useState)(null);
83
+ const pendingSeenRef = (0, import_react.useRef)(false);
84
+ const connected = status?.connected ?? false;
85
+ const refresh = (0, import_react.useCallback)(async () => {
86
+ try {
87
+ setStatus(await api("/status"));
88
+ } catch {
89
+ }
90
+ }, []);
91
+ (0, import_react.useEffect)(() => {
92
+ refresh();
93
+ const poll = window.setInterval(refresh, POLL_MS);
94
+ return () => window.clearInterval(poll);
95
+ }, [refresh]);
96
+ (0, import_react.useEffect)(() => {
97
+ if (status?.loginPending) pendingSeenRef.current = true;
98
+ }, [status?.loginPending]);
99
+ const waiting = busy && note?.kind === "info";
100
+ (0, import_react.useEffect)(() => {
101
+ if (!waiting || status === null) return;
102
+ if (status.connected) {
103
+ setBusy(false);
104
+ setNote(null);
105
+ } else if (pendingSeenRef.current && !status.loginPending) {
106
+ setBusy(false);
107
+ setNote({ kind: "err", text: "Authorization was not completed \u2014 please try again." });
108
+ }
109
+ }, [waiting, status]);
110
+ const login = (0, import_react.useCallback)(async () => {
111
+ setBusy(true);
112
+ setNote(null);
113
+ try {
114
+ const r = await api("/login", {});
115
+ if (!r.ok || !r.url) throw new Error(r.error ?? "login failed");
116
+ window.open(r.url, "_blank", "noopener");
117
+ setNote({ kind: "info", text: "Waiting for confirmation in the browser\u2026" });
118
+ } catch (e) {
119
+ setBusy(false);
120
+ setNote({ kind: "err", text: e instanceof Error ? e.message : String(e) });
121
+ }
122
+ }, []);
123
+ const logout = (0, import_react.useCallback)(async () => {
124
+ setBusy(true);
125
+ setNote(null);
126
+ try {
127
+ await api("/logout", {});
128
+ await refresh();
129
+ } catch (e) {
130
+ setNote({ kind: "err", text: e instanceof Error ? e.message : String(e) });
131
+ } finally {
132
+ setBusy(false);
133
+ }
134
+ }, [refresh]);
135
+ const view = status === null ? { label: "Checking\u2026", mark: "\u22EF", box: styles.statusPending, markStyle: styles.glyphPending } : connected ? { label: "Connected", mark: "\u2713", box: styles.statusOn, markStyle: styles.glyphOn } : { label: "Not connected", mark: "\u2715", box: styles.statusOff, markStyle: styles.glyphOff };
136
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.page, children: [
137
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", { style: styles.title, children: "Notion (OAuth)" }),
138
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { ...styles.status, ...view.box }, children: [
139
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { ...styles.glyph, ...view.markStyle }, children: view.mark }),
140
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: view.label })
141
+ ] }),
142
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.row, children: [
143
+ !connected && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
144
+ "button",
145
+ {
146
+ style: busy ? { ...styles.buttonPrimary, ...styles.buttonDisabled } : styles.buttonPrimary,
147
+ disabled: busy,
148
+ onClick: login,
149
+ children: "Login"
150
+ }
151
+ ),
152
+ connected && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
153
+ "button",
154
+ {
155
+ style: busy ? { ...styles.button, ...styles.buttonDisabled } : styles.button,
156
+ disabled: busy,
157
+ onClick: logout,
158
+ children: "Logout"
159
+ }
160
+ )
161
+ ] }),
162
+ note !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: note.kind === "info" ? styles.info : styles.err, children: note.text }),
163
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.hint, children: [
164
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.hintP, children: "Authorizes through the official Notion MCP (OAuth) \u2014 no integration tokens. Permissions come from your Notion account." }),
165
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.hintP, children: "After signing in, the mcp__notion__* tools become available (search, read and create pages, databases)." }),
166
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.hintP, children: "The token renews in the background, so you only sign in once." })
167
+ ] })
168
+ ] });
169
+ }
170
+
171
+ // src/client/index.ts
172
+ var inject = ["slots"];
173
+ function apply(ctx) {
174
+ ctx.slots.inject(
175
+ "settings.section",
176
+ () => ctx.slots.register(
177
+ { name: "settings.section", id: "notion", order: 30, label: "Notion" },
178
+ () => (0, import_react2.createElement)(NotionSettings)
179
+ )
180
+ );
181
+ }
182
+
183
+ return module.exports;
184
+ }
185
+ });
package/lib/index.js ADDED
@@ -0,0 +1,628 @@
1
+ // src/index.ts
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { Command } from "commander";
4
+ import { parseCmdline } from "@deepseek-ai/dsh-cmdline";
5
+ import * as mcpClient from "@deepseek-ai/dsh-mcp-client";
6
+
7
+ // src/notion-oauth.ts
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ var UA = `dsh-notion-oauth/${"1.0.0"}`;
10
+ function withUA(init = {}) {
11
+ const headers = new Headers(init.headers);
12
+ if (!headers.has("User-Agent")) headers.set("User-Agent", UA);
13
+ return { ...init, headers };
14
+ }
15
+ function withTimeout(ms, init = {}) {
16
+ const signal = init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms);
17
+ return { ...init, signal };
18
+ }
19
+ function base64url(buf) {
20
+ return buf.toString("base64url");
21
+ }
22
+ function generateVerifier() {
23
+ return base64url(randomBytes(32));
24
+ }
25
+ function computeChallenge(verifier) {
26
+ return base64url(createHash("sha256").update(verifier).digest());
27
+ }
28
+ function generateState() {
29
+ return base64url(randomBytes(16));
30
+ }
31
+ async function fetchJson(url, init = {}, timeoutMs = 3e4) {
32
+ const res = await fetch(url, withUA(withTimeout(timeoutMs, init)));
33
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
34
+ return res.json();
35
+ }
36
+ async function discoverOAuth(resourceBaseUrl) {
37
+ const origin = new URL(resourceBaseUrl).origin;
38
+ const doc = await fetchJson(`${origin}/.well-known/oauth-protected-resource`);
39
+ const authServer = doc.authorization_servers?.[0];
40
+ if (!authServer) throw new Error("OAuth discovery: no authorization_servers advertised");
41
+ if (!/^https:\/\//i.test(authServer)) {
42
+ throw new Error("OAuth discovery: authorization server must be https");
43
+ }
44
+ const meta = await fetchJson(`${authServer}/.well-known/oauth-authorization-server`);
45
+ const endpoints = {
46
+ authorizationEndpoint: meta.authorization_endpoint,
47
+ tokenEndpoint: meta.token_endpoint,
48
+ registrationEndpoint: meta.registration_endpoint
49
+ };
50
+ for (const [key, value] of Object.entries(endpoints)) {
51
+ if (typeof value !== "string" || !/^https:\/\//i.test(value)) {
52
+ throw new Error(`OAuth discovery: ${key} must be https`);
53
+ }
54
+ }
55
+ return endpoints;
56
+ }
57
+ async function registerClient(registrationEndpoint, redirectUris) {
58
+ const res = await fetch(registrationEndpoint, withUA(withTimeout(3e4, {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify({
62
+ client_name: "dsh-notion-oauth",
63
+ redirect_uris: redirectUris,
64
+ token_endpoint_auth_method: "none",
65
+ grant_types: ["authorization_code", "refresh_token"]
66
+ })
67
+ })));
68
+ if (!res.ok) throw new Error(`DCR failed: HTTP ${res.status}`);
69
+ const json = await res.json();
70
+ return json.client_id;
71
+ }
72
+ function buildAuthorizeUrl(authorizationEndpoint, opts) {
73
+ const url = new URL(authorizationEndpoint);
74
+ url.searchParams.set("response_type", "code");
75
+ url.searchParams.set("client_id", opts.clientId);
76
+ url.searchParams.set("redirect_uri", opts.redirectUri);
77
+ url.searchParams.set("state", opts.state);
78
+ url.searchParams.set("code_challenge", opts.codeChallenge);
79
+ url.searchParams.set("code_challenge_method", "S256");
80
+ return url.toString();
81
+ }
82
+ function parseTokenBody(body) {
83
+ const accessToken = body.access_token;
84
+ const expiresIn = body.expires_in;
85
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
86
+ throw new Error("token response missing access_token");
87
+ }
88
+ if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
89
+ throw new Error("token response missing or invalid expires_in");
90
+ }
91
+ return { accessToken, refreshToken: body.refresh_token, expiresIn };
92
+ }
93
+ async function exchangeCode(tokenEndpoint, opts) {
94
+ const res = await fetch(tokenEndpoint, withUA(withTimeout(6e4, {
95
+ method: "POST",
96
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
97
+ body: new URLSearchParams({
98
+ grant_type: "authorization_code",
99
+ client_id: opts.clientId,
100
+ code: opts.code,
101
+ redirect_uri: opts.redirectUri,
102
+ code_verifier: opts.codeVerifier
103
+ })
104
+ })));
105
+ if (!res.ok) throw new Error(`token exchange failed: HTTP ${res.status}`);
106
+ return parseTokenBody(await res.json());
107
+ }
108
+ var InvalidGrantError = class extends Error {
109
+ constructor() {
110
+ super("invalid_grant: refresh token expired or rotated away \u2014 re-authorize required");
111
+ }
112
+ };
113
+ async function refreshAccessToken(tokenEndpoint, opts) {
114
+ const res = await fetch(tokenEndpoint, withUA(withTimeout(6e4, {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
117
+ body: new URLSearchParams({
118
+ grant_type: "refresh_token",
119
+ client_id: opts.clientId,
120
+ refresh_token: opts.refreshToken
121
+ })
122
+ })));
123
+ const body = await res.json().catch(() => ({}));
124
+ if (body.error === "invalid_grant") throw new InvalidGrantError();
125
+ if (!res.ok) throw new Error(`refresh failed: HTTP ${res.status}`);
126
+ return parseTokenBody(body);
127
+ }
128
+
129
+ // src/token-store.ts
130
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
131
+ var REF = credentialRef("NOTION_OAUTH");
132
+ function isStoredTokens(value) {
133
+ if (typeof value !== "object" || value === null) return false;
134
+ const v = value;
135
+ return typeof v.accessToken === "string" && v.accessToken.length > 0 && typeof v.refreshToken === "string" && v.refreshToken.length > 0 && typeof v.clientId === "string" && v.clientId.length > 0 && typeof v.expiresAt === "number" && Number.isFinite(v.expiresAt);
136
+ }
137
+ var NotionTokenStore = class {
138
+ credentials;
139
+ constructor(credentials) {
140
+ this.credentials = credentials;
141
+ }
142
+ async load() {
143
+ const resolved = await this.credentials.resolve(REF);
144
+ if (!resolved) return void 0;
145
+ try {
146
+ const parsed = JSON.parse(resolved.value);
147
+ return isStoredTokens(parsed) ? parsed : void 0;
148
+ } catch {
149
+ return void 0;
150
+ }
151
+ }
152
+ async save(tokens) {
153
+ await this.credentials.set(REF, JSON.stringify(tokens));
154
+ }
155
+ async clear() {
156
+ await this.credentials.unset(REF);
157
+ }
158
+ };
159
+
160
+ // src/login-server.ts
161
+ import { createServer } from "node:http";
162
+ var DEFAULT_TIMEOUT_MS = 10 * 60 * 1e3;
163
+ function page(options) {
164
+ const accent = options.ok ? "#16a34a" : "#dc2626";
165
+ const wash = options.ok ? "rgba(22,163,74,.12)" : "rgba(220,38,38,.12)";
166
+ const glyph = options.ok ? "&#10003;" : "&#10007;";
167
+ return `<!doctype html>
168
+ <html lang="en">
169
+ <head>
170
+ <meta charset="utf-8">
171
+ <meta name="viewport" content="width=device-width, initial-scale=1">
172
+ <title>Notion \xB7 DeepSeek Harness</title>
173
+ <style>
174
+ :root { color-scheme: light dark; }
175
+ * { box-sizing: border-box; }
176
+ body {
177
+ margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px;
178
+ font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
179
+ background: #f5f6f8; color: #1f2328;
180
+ }
181
+ @media (prefers-color-scheme: dark) { body { background: #15171c; color: #e7e9ec; } }
182
+ .card {
183
+ width: min(430px, 100%); padding: 30px 28px 24px; text-align: center;
184
+ background: #fff; border: 1px solid rgba(127,127,127,.2); border-radius: 16px;
185
+ box-shadow: 0 10px 30px rgba(0,0,0,.07);
186
+ }
187
+ @media (prefers-color-scheme: dark) { .card { background: #1d2026; border-color: rgba(255,255,255,.08); } }
188
+ .badge {
189
+ width: 54px; height: 54px; margin: 0 auto 18px; border-radius: 50%;
190
+ display: grid; place-items: center; font-size: 27px; font-weight: 700;
191
+ color: ${accent}; background: ${wash};
192
+ }
193
+ h1 { margin: 0 0 8px; font-size: 19px; font-weight: 650; letter-spacing: -.01em; }
194
+ p.detail { margin: 0; font-size: 14px; opacity: .72; }
195
+ .brand {
196
+ margin-top: 22px; padding-top: 14px; border-top: 1px solid rgba(127,127,127,.18);
197
+ font-size: 11.5px; letter-spacing: .04em; text-transform: uppercase; opacity: .45;
198
+ }
199
+ </style>
200
+ </head>
201
+ <body>
202
+ <main class="card">
203
+ <div class="badge">${glyph}</div>
204
+ <h1>${options.title}</h1>
205
+ <p class="detail">${options.detail}</p>
206
+ <div class="brand">DeepSeek Harness \xB7 Notion</div>
207
+ </main>
208
+ <script>${options.ok ? "setTimeout(function(){ window.close(); }, 900);" : ""}</script>
209
+ </body>
210
+ </html>
211
+ `;
212
+ }
213
+ function startLoginServer(expectedState, port, timeoutMs = DEFAULT_TIMEOUT_MS) {
214
+ return new Promise((resolveListen, rejectListen) => {
215
+ let settled = false;
216
+ let resolveWait;
217
+ let rejectWait;
218
+ const wait = new Promise((resolve, reject) => {
219
+ resolveWait = resolve;
220
+ rejectWait = reject;
221
+ });
222
+ wait.catch(() => {
223
+ });
224
+ let timer;
225
+ const finish = (settle) => {
226
+ if (settled) return;
227
+ settled = true;
228
+ if (timer !== void 0) clearTimeout(timer);
229
+ server.close();
230
+ settle();
231
+ };
232
+ const server = createServer((req, res) => {
233
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
234
+ const reply = (body, status = 200) => {
235
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
236
+ res.end(body);
237
+ };
238
+ if (url.pathname !== "/callback") {
239
+ res.writeHead(404).end();
240
+ return;
241
+ }
242
+ const error = url.searchParams.get("error");
243
+ if (error) {
244
+ reply(page({
245
+ ok: false,
246
+ title: "Access denied",
247
+ detail: "Notion declined the authorization. Return to settings and try again."
248
+ }));
249
+ finish(() => rejectWait(new Error(`OAuth error: ${error}`)));
250
+ return;
251
+ }
252
+ const code = url.searchParams.get("code");
253
+ const state = url.searchParams.get("state");
254
+ if (!code || !state) {
255
+ reply(page({
256
+ ok: false,
257
+ title: "Invalid response",
258
+ detail: "The callback is missing the authorization code. Please try again."
259
+ }), 400);
260
+ return;
261
+ }
262
+ if (state !== expectedState) {
263
+ reply(page({
264
+ ok: false,
265
+ title: "Verification failed",
266
+ detail: "This request does not match the authorization that was started."
267
+ }), 400);
268
+ return;
269
+ }
270
+ reply(page({
271
+ ok: true,
272
+ title: "Authorization received",
273
+ detail: "Close this tab \u2014 the connection is finishing in DeepSeek Harness."
274
+ }));
275
+ finish(() => resolveWait({ code, state }));
276
+ });
277
+ server.on("error", (e) => {
278
+ if (settled) return;
279
+ settled = true;
280
+ if (timer !== void 0) clearTimeout(timer);
281
+ rejectListen(e);
282
+ });
283
+ timer = setTimeout(() => {
284
+ finish(() => rejectWait(new Error(`login timed out after ${Math.round(timeoutMs / 1e3)}s`)));
285
+ }, timeoutMs);
286
+ timer.unref?.();
287
+ server.listen(port, "127.0.0.1", () => {
288
+ const actual = server.address().port;
289
+ resolveListen({
290
+ redirectUri: `http://127.0.0.1:${actual}/callback`,
291
+ wait,
292
+ close: () => finish(() => rejectWait(new Error("login cancelled")))
293
+ });
294
+ });
295
+ });
296
+ }
297
+
298
+ // src/index.ts
299
+ var name = "notion";
300
+ var inject = ["credentials", "cmdlineArgs"];
301
+ var Config = z.object({
302
+ mcpUrl: z.string().default("https://mcp.notion.com/mcp"),
303
+ port: z.number().default(53007),
304
+ refreshLeadMs: z.number().default(5 * 60 * 1e3),
305
+ refreshRetryMs: z.number().default(60 * 1e3)
306
+ });
307
+ var API_PREFIX = "/api/dsh-notion-oauth";
308
+ function apply(ctx, config) {
309
+ const store = new NotionTokenStore(ctx.credentials);
310
+ if (!/^https:\/\//i.test(config.mcpUrl)) {
311
+ throw new Error(`dsh-notion-oauth: mcpUrl must be an https:// URL (got "${config.mcpUrl}")`);
312
+ }
313
+ const slot = {};
314
+ let pending;
315
+ let activeLoginClose;
316
+ let mounted = false;
317
+ let refreshTimer;
318
+ let loginStarting = false;
319
+ let tokenLock = Promise.resolve();
320
+ function unmount() {
321
+ if (refreshTimer !== void 0) {
322
+ clearTimeout(refreshTimer);
323
+ refreshTimer = void 0;
324
+ }
325
+ if (slot.child) {
326
+ slot.child.dispose();
327
+ slot.child = void 0;
328
+ }
329
+ mounted = false;
330
+ }
331
+ function mount(accessToken) {
332
+ unmount();
333
+ slot.child = ctx.plugin(mcpClient, {
334
+ transport: "streamable-http",
335
+ serverName: "notion",
336
+ url: config.mcpUrl,
337
+ headers: { Authorization: `Bearer ${accessToken}` },
338
+ toolCallTimeoutMs: 6e4,
339
+ failOnStartupError: false
340
+ });
341
+ mounted = true;
342
+ }
343
+ function armRefresh(delayMs) {
344
+ if (refreshTimer !== void 0) clearTimeout(refreshTimer);
345
+ refreshTimer = setTimeout(() => {
346
+ void runRefresh();
347
+ }, delayMs);
348
+ refreshTimer.unref?.();
349
+ }
350
+ async function runRefresh() {
351
+ try {
352
+ await refreshAndMount();
353
+ } catch (e) {
354
+ console.error("[dsh-notion-oauth] refresh failed:", e);
355
+ if (await store.load()) armRefresh(config.refreshRetryMs);
356
+ }
357
+ }
358
+ function mountAndSchedule(accessToken, expiresAt) {
359
+ mount(accessToken);
360
+ armRefresh(Math.max(3e4, expiresAt - Date.now() - config.refreshLeadMs));
361
+ }
362
+ function withTokenLock(fn) {
363
+ const run = tokenLock.then(fn, fn);
364
+ tokenLock = run.then(() => void 0, () => void 0);
365
+ return run;
366
+ }
367
+ function refreshAndMount() {
368
+ return withTokenLock(async () => {
369
+ const tokens = await store.load();
370
+ if (!tokens) return;
371
+ const disc = await discoverOAuth(config.mcpUrl);
372
+ let next;
373
+ try {
374
+ next = await refreshAccessToken(disc.tokenEndpoint, {
375
+ clientId: tokens.clientId,
376
+ refreshToken: tokens.refreshToken
377
+ });
378
+ } catch (e) {
379
+ if (e instanceof InvalidGrantError) {
380
+ await store.clear();
381
+ unmount();
382
+ return;
383
+ }
384
+ throw e;
385
+ }
386
+ const refreshed = {
387
+ accessToken: next.accessToken,
388
+ refreshToken: next.refreshToken ?? tokens.refreshToken,
389
+ expiresAt: Date.now() + next.expiresIn * 1e3,
390
+ clientId: tokens.clientId
391
+ };
392
+ await store.save(refreshed);
393
+ mountAndSchedule(refreshed.accessToken, refreshed.expiresAt);
394
+ });
395
+ }
396
+ function completePending(code) {
397
+ return withTokenLock(async () => {
398
+ if (!pending) throw new Error("no pending login");
399
+ const { endpoints, clientId, verifier, redirectUri } = pending;
400
+ const tokens = await exchangeCode(endpoints.tokenEndpoint, {
401
+ clientId,
402
+ code,
403
+ redirectUri,
404
+ codeVerifier: verifier
405
+ });
406
+ if (!tokens.refreshToken) throw new Error("authorization response missing refresh_token");
407
+ const stored = {
408
+ accessToken: tokens.accessToken,
409
+ refreshToken: tokens.refreshToken,
410
+ expiresAt: Date.now() + tokens.expiresIn * 1e3,
411
+ clientId
412
+ };
413
+ await store.save(stored);
414
+ mountAndSchedule(stored.accessToken, stored.expiresAt);
415
+ });
416
+ }
417
+ async function beginLogin() {
418
+ if (loginStarting) throw new Error("login already in progress");
419
+ loginStarting = true;
420
+ try {
421
+ if (activeLoginClose) {
422
+ activeLoginClose();
423
+ activeLoginClose = void 0;
424
+ pending = void 0;
425
+ }
426
+ const endpoints = await discoverOAuth(config.mcpUrl);
427
+ const verifier = generateVerifier();
428
+ const state = generateState();
429
+ const { redirectUri, wait, close } = await startLoginServer(state, config.port);
430
+ activeLoginClose = close;
431
+ try {
432
+ const clientId = await registerClient(endpoints.registrationEndpoint, [redirectUri]);
433
+ const myPending = { endpoints, clientId, verifier, redirectUri };
434
+ pending = myPending;
435
+ const url = buildAuthorizeUrl(endpoints.authorizationEndpoint, {
436
+ clientId,
437
+ redirectUri,
438
+ state,
439
+ codeChallenge: computeChallenge(verifier)
440
+ });
441
+ const done = wait.then(({ code }) => completePending(code)).finally(() => {
442
+ if (activeLoginClose === close) activeLoginClose = void 0;
443
+ if (pending === myPending) pending = void 0;
444
+ });
445
+ done.catch((e) => console.error("[dsh-notion-oauth] login failed:", e));
446
+ return { url, done };
447
+ } catch (e) {
448
+ close();
449
+ if (activeLoginClose === close) activeLoginClose = void 0;
450
+ throw e;
451
+ }
452
+ } finally {
453
+ loginStarting = false;
454
+ }
455
+ }
456
+ function isTrustedRequest(req, method) {
457
+ if (req.method !== method) return false;
458
+ const addr = req.socket.remoteAddress;
459
+ if (addr !== "127.0.0.1" && addr !== "::1" && addr !== "::ffff:127.0.0.1") return false;
460
+ const host = req.headers.host;
461
+ if (typeof host !== "string") return false;
462
+ let hostUrl;
463
+ try {
464
+ hostUrl = new URL(`http://${host}`);
465
+ } catch {
466
+ return false;
467
+ }
468
+ const hn = hostUrl.hostname;
469
+ if (hn !== "127.0.0.1" && hn !== "localhost" && hn !== "[::1]") return false;
470
+ if (req.headers["sec-fetch-site"] === "cross-site") return false;
471
+ const origin = req.headers.origin;
472
+ if (origin !== void 0 && origin !== "dsh-app://app") {
473
+ try {
474
+ if (new URL(origin).host !== hostUrl.host) return false;
475
+ } catch {
476
+ return false;
477
+ }
478
+ }
479
+ if (method === "POST") {
480
+ const contentType = req.headers["content-type"];
481
+ if (typeof contentType !== "string" || !/^application\/json\b/i.test(contentType)) return false;
482
+ }
483
+ return true;
484
+ }
485
+ function writeJson(res, status, value) {
486
+ res.writeHead(status, { "Content-Type": "application/json" });
487
+ res.end(JSON.stringify(value));
488
+ }
489
+ const routes = [
490
+ {
491
+ kind: "exact",
492
+ path: `${API_PREFIX}/status`,
493
+ handler: async (req, res) => {
494
+ if (!isTrustedRequest(req, "GET")) {
495
+ writeJson(res, 403, { error: "forbidden" });
496
+ return;
497
+ }
498
+ const tokens = await store.load();
499
+ const expiresAt = tokens?.expiresAt ?? null;
500
+ writeJson(res, 200, {
501
+ connected: mounted && expiresAt !== null && expiresAt > Date.now(),
502
+ mounted,
503
+ expiresAt,
504
+ loginPending: activeLoginClose !== void 0
505
+ });
506
+ }
507
+ },
508
+ {
509
+ kind: "exact",
510
+ path: `${API_PREFIX}/login`,
511
+ handler: async (req, res) => {
512
+ if (!isTrustedRequest(req, "POST")) {
513
+ writeJson(res, 403, { error: "forbidden" });
514
+ return;
515
+ }
516
+ try {
517
+ const { url } = await beginLogin();
518
+ writeJson(res, 200, { ok: true, url });
519
+ } catch (e) {
520
+ writeJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
521
+ }
522
+ }
523
+ },
524
+ {
525
+ kind: "exact",
526
+ path: `${API_PREFIX}/logout`,
527
+ handler: async (req, res) => {
528
+ if (!isTrustedRequest(req, "POST")) {
529
+ writeJson(res, 403, { error: "forbidden" });
530
+ return;
531
+ }
532
+ if (activeLoginClose) {
533
+ activeLoginClose();
534
+ activeLoginClose = void 0;
535
+ pending = void 0;
536
+ }
537
+ await store.clear();
538
+ unmount();
539
+ writeJson(res, 200, { ok: true });
540
+ }
541
+ }
542
+ ];
543
+ ctx.inject(["webServer"], (ctx2) => {
544
+ const disposers = routes.map((r) => ctx2.webServer.register(r));
545
+ return () => {
546
+ for (const d of disposers) d?.();
547
+ };
548
+ });
549
+ const isNotionCommand = (ctx.cmdlineArgs?.get?.() ?? [])[0] === "notion";
550
+ if (isNotionCommand) {
551
+ const program = new Command();
552
+ const notion = program.command("notion").description("Notion OAuth connection");
553
+ notion.command("login").description("Authorize Notion via the official MCP OAuth flow").action(async () => {
554
+ try {
555
+ const { url, done } = await beginLogin();
556
+ console.log(`[dsh-notion-oauth] open this URL to authorize Notion:
557
+ ${url}`);
558
+ await done;
559
+ console.log("[dsh-notion-oauth] connected");
560
+ ctx.appExit?.(0);
561
+ } catch (e) {
562
+ console.error(e);
563
+ ctx.appExit?.(1);
564
+ }
565
+ });
566
+ notion.command("logout").description("Disconnect Notion and forget the stored token").action(async () => {
567
+ try {
568
+ if (activeLoginClose) {
569
+ activeLoginClose();
570
+ activeLoginClose = void 0;
571
+ pending = void 0;
572
+ }
573
+ await store.clear();
574
+ unmount();
575
+ console.log("[dsh-notion-oauth] disconnected");
576
+ ctx.appExit?.(0);
577
+ } catch (e) {
578
+ console.error(e);
579
+ ctx.appExit?.(1);
580
+ }
581
+ });
582
+ notion.command("status").description("Show the stored Notion token state").action(async () => {
583
+ try {
584
+ const tokens = await store.load();
585
+ const valid = tokens !== void 0 && tokens.expiresAt > Date.now();
586
+ console.log(JSON.stringify({
587
+ token: tokens === void 0 ? "none" : valid ? "valid" : "expired",
588
+ expiresAt: tokens?.expiresAt ?? null
589
+ }, null, 2));
590
+ ctx.appExit?.(0);
591
+ } catch (e) {
592
+ console.error(e);
593
+ ctx.appExit?.(1);
594
+ }
595
+ });
596
+ parseCmdline(ctx, program);
597
+ }
598
+ if (!isNotionCommand) {
599
+ (async () => {
600
+ try {
601
+ const tokens = await store.load();
602
+ if (!tokens) return;
603
+ if (tokens.expiresAt > Date.now() + 6e4) {
604
+ mountAndSchedule(tokens.accessToken, tokens.expiresAt);
605
+ } else {
606
+ await refreshAndMount();
607
+ }
608
+ } catch (e) {
609
+ console.error("[dsh-notion-oauth] startup error:", e);
610
+ if (await store.load()) armRefresh(config.refreshRetryMs);
611
+ }
612
+ })();
613
+ }
614
+ ctx.effect(() => () => {
615
+ if (activeLoginClose) {
616
+ activeLoginClose();
617
+ activeLoginClose = void 0;
618
+ pending = void 0;
619
+ }
620
+ unmount();
621
+ });
622
+ }
623
+ export {
624
+ Config,
625
+ apply,
626
+ inject,
627
+ name
628
+ };
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Notion",
4
+ "description": "Connect Notion through the official Notion MCP with OAuth 2.0 + PKCE — sign in from the GUI."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Notion",
4
+ "description": "通过官方 Notion MCP 以 OAuth 2.0 + PKCE 连接 Notion —— 在图形界面中登录。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "dsh-notion-oauth",
3
+ "version": "1.0.0",
4
+ "description": "Connect Notion through the official Notion MCP with OAuth 2.0 + PKCE — sign in from the GUI.",
5
+ "icon": "./icon.svg",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "author": "Merkurev Sergei <merkurevsn@gmail.com> (https://github.com/MerkurevSergei)",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MerkurevSergei/dsh-notion-oauth.git"
12
+ },
13
+ "homepage": "https://github.com/MerkurevSergei/dsh-notion-oauth#readme",
14
+ "bugs": { "url": "https://github.com/MerkurevSergei/dsh-notion-oauth/issues" },
15
+ "engines": { "node": ">=22.12.0", "dsh": ">=0.1.5-rc.3" },
16
+ "packageManager": "pnpm@11.7.0",
17
+ "main": "lib/index.js",
18
+ "exports": {
19
+ ".": "./lib/index.js",
20
+ "./client": "./lib/client.js",
21
+ "./locale/*.json": "./locale/*.json",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": ["lib", "locale", "icon.svg", "cordis.patch.yml", "README.md", "README.zh.md", "CHANGELOG.md", "LICENSE"],
25
+ "scripts": {
26
+ "build": "node scripts/build.mjs",
27
+ "typecheck": "tsc --noEmit",
28
+ "prepare": "node scripts/build.mjs"
29
+ },
30
+ "dsh": {
31
+ "bundle": { "patch": "./cordis.patch.yml" },
32
+ "client": { "inject": ["@deepseek-ai/dsh-client-runtime"], "platform": "web" }
33
+ },
34
+ "dependencies": { "commander": "^15.0.0" },
35
+ "peerDependencies": {
36
+ "react": ">=18"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "react": { "optional": true }
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.0.0",
43
+ "@types/react": "^18.3.0",
44
+ "esbuild": "^0.25.0",
45
+ "typescript": "^5.6.0"
46
+ },
47
+ "keywords": ["dsh", "dsh-plugin", "notion", "mcp", "oauth", "deepseek-harness"]
48
+ }
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
65
+
66
+
67
+
68
+
69
+