dsh-plugin-subscriptions 0.1.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.
Files changed (44) hide show
  1. package/README.md +93 -0
  2. package/README.zh.md +93 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/auth/jwt.d.ts +10 -0
  5. package/lib/auth/jwt.js +25 -0
  6. package/lib/auth/oauth-flow.d.ts +91 -0
  7. package/lib/auth/oauth-flow.js +227 -0
  8. package/lib/auth/pkce.d.ts +31 -0
  9. package/lib/auth/pkce.js +35 -0
  10. package/lib/auth/rpc.d.ts +51 -0
  11. package/lib/auth/rpc.js +83 -0
  12. package/lib/auth/store.d.ts +90 -0
  13. package/lib/auth/store.js +137 -0
  14. package/lib/client/SubscriptionsSection.d.ts +30 -0
  15. package/lib/client/SubscriptionsSection.js +290 -0
  16. package/lib/client/index.d.ts +31 -0
  17. package/lib/client/index.js +35 -0
  18. package/lib/client/locales.d.ts +45 -0
  19. package/lib/client/locales.js +43 -0
  20. package/lib/client.js +546 -0
  21. package/lib/client.js.map +1 -0
  22. package/lib/index.d.ts +34 -0
  23. package/lib/index.js +2932 -0
  24. package/lib/providers/claude.d.ts +60 -0
  25. package/lib/providers/claude.js +243 -0
  26. package/lib/providers/codex.d.ts +96 -0
  27. package/lib/providers/codex.js +391 -0
  28. package/lib/providers/common.d.ts +185 -0
  29. package/lib/providers/common.js +302 -0
  30. package/lib/providers/grok.d.ts +90 -0
  31. package/lib/providers/grok.js +337 -0
  32. package/lib/tools/image-generate.d.ts +60 -0
  33. package/lib/tools/image-generate.js +142 -0
  34. package/lib/tools/x-search.d.ts +58 -0
  35. package/lib/tools/x-search.js +195 -0
  36. package/lib/translate/anthropic.d.ts +120 -0
  37. package/lib/translate/anthropic.js +370 -0
  38. package/lib/translate/resolved.d.ts +35 -0
  39. package/lib/translate/resolved.js +40 -0
  40. package/lib/translate/responses.d.ts +127 -0
  41. package/lib/translate/responses.js +352 -0
  42. package/lib/translate/sse.d.ts +21 -0
  43. package/lib/translate/sse.js +56 -0
  44. package/package.json +83 -0
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # dsh-plugin-subscriptions
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ Use your **ChatGPT (Codex)**, **Claude**, and **Grok (X Premium)** subscriptions as LLM providers in [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — no API keys. Login happens in the dsh web UI (Settings → Subscriptions); tokens live at `~/.dsh/plugins/subscriptions/auth.json` (mode 0600) and refresh automatically.
6
+
7
+ ## Providers
8
+
9
+ | Route | Subscription | Models |
10
+ |----------|-------------------|--------|
11
+ | `codex` | ChatGPT Plus/Pro | live catalog from `chatgpt.com/backend-api/codex/models` |
12
+ | `claude` | Claude Pro/Max | claude-opus-4-5, claude-sonnet-4-5, claude-haiku-4-5 |
13
+ | `grok` | X Premium (xAI) | live catalog from `api.x.ai/v1/models` (chat models only) |
14
+
15
+ Only logged-in providers appear in the session model picker; the lists above refresh on login/logout. Vision-capable models declare `['text', 'image']` input modalities, and image content is translated to each provider's wire format.
16
+
17
+ Also included, registered when the matching provider is enabled:
18
+
19
+ - **`x_search`** tool (Grok) — xAI's hosted X search, returning `{ answer, citations }`.
20
+ - **`image_generate`** tool (ChatGPT) — `gpt-image-2` via the Codex backend; PNGs are saved under `~/.dsh/plugins/subscriptions/images/` and the paths returned.
21
+
22
+ ## Install
23
+
24
+ With the `dsh` CLI available:
25
+
26
+ ```sh
27
+ dsh plugin --profile web add github:V1ki/dsh-plugin-subscriptions
28
+ ```
29
+
30
+ pnpm will ask you to allow this package's build script on first install (git installs fetch sources, not built artifacts); add the printed key to the profile's `pnpm-workspace.yaml`:
31
+
32
+ ```yaml
33
+ allowBuilds:
34
+ dsh-plugin-subscriptions: true
35
+ ```
36
+
37
+ and re-run the `add`. Only grant this to packages you trust — it runs the package's code at install time.
38
+
39
+ From a local checkout instead:
40
+
41
+ ```sh
42
+ git clone https://github.com/V1ki/dsh-plugin-subscriptions.git
43
+ cd dsh-plugin-subscriptions && pnpm install && pnpm build
44
+ dsh plugin --profile web add ./dsh-plugin-subscriptions
45
+ ```
46
+
47
+ Headless-only usage without installing into a profile (log in via the web UI first — the token file is shared):
48
+
49
+ ```sh
50
+ dsh --profile headless --patch <checkout>/overlay.yml "your task"
51
+ ```
52
+
53
+ ## Use
54
+
55
+ 1. `dsh web`, open the printed URL.
56
+ 2. Settings → **Subscriptions**: click **Log in** on a provider and authorize in the opened tab. If the browser flow can't complete (headless host), expand the manual fallback and paste the callback URL or code.
57
+ 3. In any session, open the model picker (`/model`) and choose a model under **ChatGPT (Codex)** / **Claude (Subscription)** / **Grok (Subscription)**.
58
+
59
+ Not logged in? The provider stays out of the picker, and requests fail with `MISSING_CREDENTIAL` pointing at the Settings page; nothing else breaks.
60
+
61
+ ## Config
62
+
63
+ ```yaml
64
+ - id: llm-subscriptions
65
+ name: dsh-plugin-subscriptions
66
+ config:
67
+ providers: [codex, claude] # subset; default all three
68
+ streamIdleTimeoutMs: 300000
69
+ models: # override the discovered/built-in catalogs
70
+ codex:
71
+ - { id: gpt-5.6-sol, name: GPT-5.6 Sol, contextWindow: 272000, inputModalities: [text, image] }
72
+ ```
73
+
74
+ ## Develop
75
+
76
+ ```sh
77
+ pnpm install # devDependencies link into a local deepseek-harness checkout — edit the paths first
78
+ pnpm build # tsc (lib/) + tsdown (lib/client.js browser bundle)
79
+ pnpm test # node --test over compiled unit specs
80
+ ```
81
+
82
+ `prepare` (used by git installs) runs `tsdown.prepare.config.ts`: a self-contained bundle build of both faces with all `@deepseek-ai/*` specifiers external — they resolve from the dsh installation at runtime, so this package never carries a second cordis copy.
83
+
84
+ After `pnpm build`, restart `dsh web` to pick up changes.
85
+
86
+ ## Layout
87
+
88
+ - `src/index.ts` — plugin entry: config schema, adapter registration, auth-change re-announce, RPC wiring
89
+ - `src/auth/` — PKCE/JWT helpers, token store, OAuth flow engine (temp loopback callback server), `/subscriptions-auth` RPC channel
90
+ - `src/providers/` — per-provider OAuth constants/exchange/refresh + `LlmAdapter`s
91
+ - `src/translate/` — dsh `Message[]` ⟷ OpenAI Responses / Anthropic Messages wire formats, SSE → `StreamChunk`
92
+ - `src/tools/` — `x_search` and `image_generate`
93
+ - `src/client/` — the Settings → Subscriptions page (browser half, zh/en, theme-token aware)
package/README.zh.md ADDED
@@ -0,0 +1,93 @@
1
+ # dsh-plugin-subscriptions
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 把你的 **ChatGPT(Codex)**、**Claude**、**Grok(X Premium)**订阅当作 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 LLM provider 使用 —— 不需要 API key。登录在 dsh web 界面完成(设置 → 订阅);token 保存在 `~/.dsh/plugins/subscriptions/auth.json`(权限 0600),过期自动刷新。
6
+
7
+ ## Provider 一览
8
+
9
+ | 路由 | 订阅 | 模型 |
10
+ |----------|------------------|------|
11
+ | `codex` | ChatGPT Plus/Pro | 从 `chatgpt.com/backend-api/codex/models` 实时获取 |
12
+ | `claude` | Claude Pro/Max | claude-opus-4-5、claude-sonnet-4-5、claude-haiku-4-5 |
13
+ | `grok` | X Premium (xAI) | 从 `api.x.ai/v1/models` 实时获取(仅对话模型) |
14
+
15
+ 只有已登录的 provider 才会出现在会话模型选择器里;登录/退出后列表自动刷新。支持视觉的模型会声明 `['text', 'image']` 输入模态,图片内容会被翻译成各 provider 的 wire 格式。
16
+
17
+ 随 provider 启用自动注册的工具:
18
+
19
+ - **`x_search`**(Grok)—— xAI 托管的 X 搜索,返回 `{ answer, citations }`。
20
+ - **`image_generate`**(ChatGPT)—— 经 Codex 后端调用 `gpt-image-2`;生成的 PNG 保存到 `~/.dsh/plugins/subscriptions/images/` 并返回路径。
21
+
22
+ ## 安装
23
+
24
+ 本机已有 `dsh` CLI 时:
25
+
26
+ ```sh
27
+ dsh plugin --profile web add github:V1ki/dsh-plugin-subscriptions
28
+ ```
29
+
30
+ 首次安装 pnpm 会要求允许该包的构建脚本(git 安装拉取的是源码而非构建产物);把打印出的包名加进 profile 的 `pnpm-workspace.yaml`:
31
+
32
+ ```yaml
33
+ allowBuilds:
34
+ dsh-plugin-subscriptions: true
35
+ ```
36
+
37
+ 然后重新执行 `add`。该授权会在安装时执行包的代码,只授给你信任的来源。
38
+
39
+ 本地检出安装:
40
+
41
+ ```sh
42
+ git clone https://github.com/V1ki/dsh-plugin-subscriptions.git
43
+ cd dsh-plugin-subscriptions && pnpm install && pnpm build
44
+ dsh plugin --profile web add ./dsh-plugin-subscriptions
45
+ ```
46
+
47
+ 不装进 profile 的 headless 用法(先在 web 界面登录过 —— token 文件是共享的):
48
+
49
+ ```sh
50
+ dsh --profile headless --patch <检出目录>/overlay.yml "你的任务"
51
+ ```
52
+
53
+ ## 使用
54
+
55
+ 1. `dsh web`,打开打印的 URL。
56
+ 2. **设置 → 订阅**:点对应 provider 的「登录」,在打开的标签页里授权。无浏览器环境下可展开手动兜底,粘贴回调 URL 或授权码。
57
+ 3. 在任意会话里打开模型选择器(`/model`),选择 **ChatGPT (Codex)** / **Claude (Subscription)** / **Grok (Subscription)** 下的模型。
58
+
59
+ 未登录时:该 provider 不出现在选择器里;直接请求会报 `MISSING_CREDENTIAL` 并提示去设置页登录,不影响其他功能。
60
+
61
+ ## 配置
62
+
63
+ ```yaml
64
+ - id: llm-subscriptions
65
+ name: dsh-plugin-subscriptions
66
+ config:
67
+ providers: [codex, claude] # 子集;默认三个全启用
68
+ streamIdleTimeoutMs: 300000
69
+ models: # 覆盖实时发现/内置目录
70
+ codex:
71
+ - { id: gpt-5.6-sol, name: GPT-5.6 Sol, contextWindow: 272000, inputModalities: [text, image] }
72
+ ```
73
+
74
+ ## 开发
75
+
76
+ ```sh
77
+ pnpm install # devDependencies 用 link: 指向本地 deepseek-harness 检出 —— 先改成你的路径
78
+ pnpm build # tsc(lib/)+ tsdown(lib/client.js 浏览器 bundle)
79
+ pnpm test # 编译后跑 node --test 单测
80
+ ```
81
+
82
+ `prepare`(git 安装时触发)执行 `tsdown.prepare.config.ts`:自包含打包两个面,所有 `@deepseek-ai/*` 依赖外部化 —— 运行时从 dsh 安装解析,保证不会引入第二份 cordis。
83
+
84
+ 改了代码后 `pnpm build` 并重启 `dsh web` 生效。
85
+
86
+ ## 目录结构
87
+
88
+ - `src/index.ts` —— 插件入口:配置 schema、adapter 注册、登录态变更通告、RPC 接线
89
+ - `src/auth/` —— PKCE/JWT 工具、token 存储、OAuth 流程引擎(临时本地回调服务)、`/subscriptions-auth` RPC 通道
90
+ - `src/providers/` —— 各 provider 的 OAuth 常量/换发/刷新 + `LlmAdapter` 实现
91
+ - `src/translate/` —— dsh `Message[]` 与 OpenAI Responses / Anthropic Messages 格式互转,SSE → `StreamChunk`
92
+ - `src/tools/` —— `x_search` 与 `image_generate`
93
+ - `src/client/` —— 设置 → 订阅页面(浏览器面,中英文,跟随明暗主题)
@@ -0,0 +1,12 @@
1
+ # Bundle self-activation patch: when this package is installed into a profile
2
+ # via `dsh plugin --profile web add dsh-plugin-subscriptions` (or a local
3
+ # checkout / git URL), this patch mounts the plugin's node half. The client
4
+ # half (Settings > Subscriptions page) is discovered automatically from the
5
+ # `dsh.client` declaration in package.json.
6
+ #
7
+ # Headless usage without installing into the profile (node half only; log in
8
+ # from a web profile first, the token file is shared):
9
+ # pnpm dsh --profile headless --patch <this-checkout>/overlay.yml "task"
10
+ - insert:
11
+ - id: llm-subscriptions
12
+ name: dsh-plugin-subscriptions
@@ -0,0 +1,10 @@
1
+ /** Minimal JWT payload decoding for claims extraction (no signature verification). */
2
+ /**
3
+ * Decode a JWT payload without verifying the signature. Used only to read
4
+ * account claims from `id_token`s issued over the provider's own TLS channel
5
+ * during a code exchange we initiated — never to authorize anything.
6
+ * @param token - the compact JWT string.
7
+ * @returns the parsed payload object, or `undefined` when the token is not a
8
+ * well-formed JWT with a JSON object payload.
9
+ */
10
+ export declare function decodeJwtPayload(token: string): Record<string, unknown> | undefined;
@@ -0,0 +1,25 @@
1
+ /** Minimal JWT payload decoding for claims extraction (no signature verification). */
2
+ /**
3
+ * Decode a JWT payload without verifying the signature. Used only to read
4
+ * account claims from `id_token`s issued over the provider's own TLS channel
5
+ * during a code exchange we initiated — never to authorize anything.
6
+ * @param token - the compact JWT string.
7
+ * @returns the parsed payload object, or `undefined` when the token is not a
8
+ * well-formed JWT with a JSON object payload.
9
+ */
10
+ export function decodeJwtPayload(token) {
11
+ const parts = token.split('.');
12
+ if (parts.length < 2)
13
+ return undefined;
14
+ let parsed;
15
+ try {
16
+ parsed = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
17
+ }
18
+ catch {
19
+ // Malformed base64url or non-JSON payload: the token is unusable for claims.
20
+ return undefined;
21
+ }
22
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
23
+ return undefined;
24
+ return parsed;
25
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Generic OAuth authorization-code flow engine: one temporary loopback HTTP
3
+ * server per login attempt receives the provider's redirect, validates
4
+ * `state`, and yields the authorization `code`. A pasted callback URL or bare
5
+ * code can substitute for the browser redirect (`manual`), and the user can
6
+ * abort (`cancel`). At most one attempt per provider runs at a time.
7
+ */
8
+ import { type PkcePair } from './pkce.js';
9
+ /** Default attempt lifetime: three minutes for the user to complete login. */
10
+ export declare const DEFAULT_FLOW_TIMEOUT_MS = 180000;
11
+ /** Where the temporary callback server listens; port 0 asks the OS for an ephemeral port. */
12
+ export interface ListenSpec {
13
+ host: string;
14
+ /** Tried in order; the first free port wins (covers the codex 1455→1457 fallback). */
15
+ ports: readonly number[];
16
+ }
17
+ /** Inputs an authorize-URL builder may need for one attempt. */
18
+ export interface AuthorizeInput {
19
+ /** Loopback redirect URI pointing at the temporary server. */
20
+ redirectUri: string;
21
+ state: string;
22
+ pkce: PkcePair;
23
+ /** Random hex nonce; only providers that require one use it. */
24
+ nonce: string;
25
+ }
26
+ /** Static per-provider flow facts. */
27
+ export interface FlowSpec {
28
+ /** Path the provider redirects to on the loopback server. */
29
+ callbackPath: string;
30
+ listen: ListenSpec;
31
+ timeoutMs?: number;
32
+ /**
33
+ * Build the provider authorize URL for one attempt.
34
+ * @param input - redirect URI, state, PKCE pair, and nonce minted for this attempt.
35
+ * @returns the URL the user's browser should open.
36
+ */
37
+ buildAuthorizeUrl(input: AuthorizeInput): string;
38
+ }
39
+ /** One in-flight login attempt. */
40
+ export interface OAuthAttempt {
41
+ /** URL to open in the user's browser. */
42
+ readonly authorizeUrl: string;
43
+ /** Redirect URI registered for this attempt (echoed at the token exchange). */
44
+ readonly redirectUri: string;
45
+ /** PKCE pair minted for this attempt. */
46
+ readonly pkce: PkcePair;
47
+ /** State parameter minted for this attempt (some providers echo it at exchange). */
48
+ readonly state: string;
49
+ /**
50
+ * Wait for the authorization code from the browser callback or `manual`.
51
+ * @returns the authorization code; rejects on timeout, provider error, or cancel.
52
+ */
53
+ waitCode(): Promise<string>;
54
+ /**
55
+ * Feed a pasted full callback URL (code + state extracted and validated) or
56
+ * a bare authorization code (state cannot be checked) into this attempt.
57
+ * @param input - the pasted text.
58
+ * @throws when the input carries no code or a mismatched state.
59
+ */
60
+ manual(input: string): void;
61
+ /** Abort the attempt and close its callback server. */
62
+ cancel(): void;
63
+ }
64
+ /**
65
+ * Own the set of in-flight login attempts, keyed by provider. One attempt per
66
+ * provider at a time; an attempt removes itself when it settles.
67
+ */
68
+ export declare class OAuthFlowManager {
69
+ private attempts;
70
+ /**
71
+ * Whether a login attempt is running for one provider.
72
+ * @param provider - the provider route.
73
+ * @returns true while an attempt is waiting for its code.
74
+ */
75
+ isBusy(provider: string): boolean;
76
+ /**
77
+ * The pending attempt for one provider, when any.
78
+ * @param provider - the provider route.
79
+ * @returns the in-flight attempt, or `undefined`.
80
+ */
81
+ pending(provider: string): OAuthAttempt | undefined;
82
+ /**
83
+ * Start a login attempt: mint PKCE/state, open the loopback callback
84
+ * server, and build the authorize URL.
85
+ * @param provider - the provider route (one attempt at a time).
86
+ * @param spec - static flow facts for this provider.
87
+ * @returns the live attempt; its `waitCode()` settles the login.
88
+ * @throws when an attempt is already running or no callback port is free.
89
+ */
90
+ start(provider: string, spec: FlowSpec): Promise<OAuthAttempt>;
91
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Generic OAuth authorization-code flow engine: one temporary loopback HTTP
3
+ * server per login attempt receives the provider's redirect, validates
4
+ * `state`, and yields the authorization `code`. A pasted callback URL or bare
5
+ * code can substitute for the browser redirect (`manual`), and the user can
6
+ * abort (`cancel`). At most one attempt per provider runs at a time.
7
+ */
8
+ import { createServer } from 'node:http';
9
+ import { createPkce, randomHex, randomToken } from './pkce.js';
10
+ /** Default attempt lifetime: three minutes for the user to complete login. */
11
+ export const DEFAULT_FLOW_TIMEOUT_MS = 180_000;
12
+ const SUCCESS_PAGE = '<!doctype html><html><head><meta charset="utf-8"><title>Login successful</title></head>'
13
+ + '<body style="font-family:sans-serif"><h1>Login successful</h1>'
14
+ + '<p>You can close this tab and return to DeepSeek Harness.</p></body></html>';
15
+ function failurePage(detail) {
16
+ return '<!doctype html><html><head><meta charset="utf-8"><title>Login failed</title></head>'
17
+ + `<body style="font-family:sans-serif"><h1>Login failed</h1><p>${detail.replace(/[<>&]/g, '')}</p></body></html>`;
18
+ }
19
+ /**
20
+ * Loopback addresses one listen host covers. `localhost` resolves to ::1 or
21
+ * 127.0.0.1 depending on the client, and Node binds exactly one of them per
22
+ * listen call — a browser picking the other family gets connection-refused
23
+ * and the login times out, so both families must serve the callback.
24
+ */
25
+ function listenHosts(host) {
26
+ return host === 'localhost' ? ['127.0.0.1', '::1'] : [host];
27
+ }
28
+ /** True when the address family does not exist on this machine (safe to skip), unlike a taken port. */
29
+ function familyUnavailable(error) {
30
+ const code = error.code;
31
+ return code === 'EADDRNOTAVAIL' || code === 'EPROTONOSUPPORT';
32
+ }
33
+ /**
34
+ * Listen on the first port of the spec free on every loopback family;
35
+ * rejects when every port fails. Ephemeral ports (0) are retried so each
36
+ * family can be re-bound onto the first family's assigned port.
37
+ */
38
+ async function listen(handler, spec) {
39
+ const hosts = listenHosts(spec.host);
40
+ const candidates = spec.ports.flatMap(port => (port === 0 ? [0, 0, 0] : [port]));
41
+ let lastError;
42
+ for (const candidate of candidates) {
43
+ const servers = [];
44
+ let port = candidate;
45
+ let unusable = false;
46
+ for (const host of hosts) {
47
+ const server = createServer(handler);
48
+ try {
49
+ await new Promise((resolve, reject) => {
50
+ const onError = (error) => reject(error);
51
+ server.once('error', onError);
52
+ server.listen(port, host, () => {
53
+ server.removeListener('error', onError);
54
+ resolve();
55
+ });
56
+ });
57
+ const address = server.address();
58
+ if (address === null)
59
+ throw new Error(`callback server on ${host}:${port} has no address`);
60
+ if (port === 0)
61
+ port = address.port;
62
+ servers.push(server);
63
+ }
64
+ catch (error) {
65
+ server.close();
66
+ if (familyUnavailable(error))
67
+ continue;
68
+ lastError = error;
69
+ unusable = true;
70
+ break;
71
+ }
72
+ }
73
+ if (unusable || servers.length === 0) {
74
+ for (const server of servers)
75
+ server.close();
76
+ continue;
77
+ }
78
+ return { servers, port };
79
+ }
80
+ throw lastError instanceof Error
81
+ ? lastError
82
+ : new Error(`callback server could not listen on ${spec.host} (ports ${spec.ports.join(', ')})`);
83
+ }
84
+ /**
85
+ * Own the set of in-flight login attempts, keyed by provider. One attempt per
86
+ * provider at a time; an attempt removes itself when it settles.
87
+ */
88
+ export class OAuthFlowManager {
89
+ attempts = new Map();
90
+ /**
91
+ * Whether a login attempt is running for one provider.
92
+ * @param provider - the provider route.
93
+ * @returns true while an attempt is waiting for its code.
94
+ */
95
+ isBusy(provider) {
96
+ return this.attempts.has(provider);
97
+ }
98
+ /**
99
+ * The pending attempt for one provider, when any.
100
+ * @param provider - the provider route.
101
+ * @returns the in-flight attempt, or `undefined`.
102
+ */
103
+ pending(provider) {
104
+ return this.attempts.get(provider);
105
+ }
106
+ /**
107
+ * Start a login attempt: mint PKCE/state, open the loopback callback
108
+ * server, and build the authorize URL.
109
+ * @param provider - the provider route (one attempt at a time).
110
+ * @param spec - static flow facts for this provider.
111
+ * @returns the live attempt; its `waitCode()` settles the login.
112
+ * @throws when an attempt is already running or no callback port is free.
113
+ */
114
+ async start(provider, spec) {
115
+ if (this.attempts.has(provider)) {
116
+ throw new Error(`a ${provider} login attempt is already in progress`);
117
+ }
118
+ const input = {
119
+ redirectUri: '',
120
+ state: randomToken(16),
121
+ pkce: createPkce(),
122
+ nonce: randomHex(8),
123
+ };
124
+ const timeoutMs = spec.timeoutMs ?? DEFAULT_FLOW_TIMEOUT_MS;
125
+ let resolveCode;
126
+ let rejectCode;
127
+ const codePromise = new Promise((resolve, reject) => {
128
+ resolveCode = resolve;
129
+ rejectCode = reject;
130
+ });
131
+ let settled = false;
132
+ let timer;
133
+ let servers = [];
134
+ const handler = (request, response) => {
135
+ const url = new URL(request.url ?? '/', 'http://localhost');
136
+ if (url.pathname !== spec.callbackPath) {
137
+ response.writeHead(404, { 'content-type': 'text/plain' });
138
+ response.end('not found');
139
+ return;
140
+ }
141
+ const errorDescription = url.searchParams.get('error_description') ?? url.searchParams.get('error');
142
+ if (errorDescription !== null) {
143
+ response.writeHead(200, { 'content-type': 'text/html' });
144
+ response.end(failurePage(errorDescription));
145
+ settle(new Error(`authorization failed: ${errorDescription}`));
146
+ return;
147
+ }
148
+ if (url.searchParams.get('state') !== input.state) {
149
+ // A stray or replayed redirect must not kill the real attempt.
150
+ response.writeHead(400, { 'content-type': 'text/plain' });
151
+ response.end('state mismatch');
152
+ return;
153
+ }
154
+ const code = url.searchParams.get('code');
155
+ if (code === null || code.length === 0) {
156
+ response.writeHead(400, { 'content-type': 'text/plain' });
157
+ response.end('missing authorization code');
158
+ return;
159
+ }
160
+ response.writeHead(200, { 'content-type': 'text/html' });
161
+ response.end(SUCCESS_PAGE);
162
+ settle(undefined, code);
163
+ };
164
+ const settle = (error, code) => {
165
+ if (settled)
166
+ return;
167
+ settled = true;
168
+ if (timer !== undefined)
169
+ clearTimeout(timer);
170
+ for (const server of servers) {
171
+ server.close();
172
+ server.closeAllConnections();
173
+ }
174
+ this.attempts.delete(provider);
175
+ if (error !== undefined)
176
+ rejectCode(error);
177
+ else if (code !== undefined)
178
+ resolveCode(code);
179
+ };
180
+ const bound = await listen(handler, spec.listen);
181
+ servers = bound.servers;
182
+ input.redirectUri = `http://${spec.listen.host}:${bound.port}${spec.callbackPath}`;
183
+ timer = setTimeout(() => {
184
+ settle(new Error(`login timed out after ${Math.round(timeoutMs / 1000)}s`));
185
+ }, timeoutMs);
186
+ timer.unref();
187
+ const attempt = {
188
+ authorizeUrl: spec.buildAuthorizeUrl(input),
189
+ redirectUri: input.redirectUri,
190
+ pkce: input.pkce,
191
+ state: input.state,
192
+ waitCode: () => codePromise,
193
+ manual(rawInput) {
194
+ if (settled)
195
+ throw new Error(`the ${provider} login attempt already finished`);
196
+ const trimmed = rawInput.trim();
197
+ let code;
198
+ let pastedState;
199
+ if (/^https?:\/\//i.test(trimmed)) {
200
+ const url = new URL(trimmed);
201
+ code = url.searchParams.get('code') ?? undefined;
202
+ pastedState = url.searchParams.get('state') ?? undefined;
203
+ }
204
+ else if (trimmed.includes('code=')) {
205
+ const params = new URLSearchParams(trimmed);
206
+ code = params.get('code') ?? undefined;
207
+ pastedState = params.get('state') ?? undefined;
208
+ }
209
+ else if (trimmed.length > 0 && !/\s/.test(trimmed)) {
210
+ code = trimmed;
211
+ }
212
+ if (code === undefined || code.length === 0) {
213
+ throw new Error('no authorization code found in the pasted input');
214
+ }
215
+ if (pastedState !== undefined && pastedState !== input.state) {
216
+ throw new Error('state mismatch: the pasted URL belongs to a different login attempt');
217
+ }
218
+ settle(undefined, code);
219
+ },
220
+ cancel() {
221
+ settle(new Error('login cancelled'));
222
+ },
223
+ };
224
+ this.attempts.set(provider, attempt);
225
+ return attempt;
226
+ }
227
+ }
@@ -0,0 +1,31 @@
1
+ /** PKCE (RFC 7636) and random-token helpers for the OAuth login flows. */
2
+ /** One PKCE verifier/challenge pair; the verifier stays local until the token exchange. */
3
+ export interface PkcePair {
4
+ /** High-entropy secret sent as `code_verifier` at the token endpoint. */
5
+ verifier: string;
6
+ /** S256 digest of the verifier, sent as `code_challenge` at the authorize endpoint. */
7
+ challenge: string;
8
+ }
9
+ /**
10
+ * Base64url-encode without padding.
11
+ * @param buffer - raw bytes.
12
+ * @returns the RFC 4648 §5 encoding.
13
+ */
14
+ export declare function base64url(buffer: Buffer): string;
15
+ /**
16
+ * Mint a fresh PKCE pair (32-byte verifier, S256 challenge).
17
+ * @returns the pair for one authorization attempt.
18
+ */
19
+ export declare function createPkce(): PkcePair;
20
+ /**
21
+ * Mint a URL-safe random token (default 16 bytes) for OAuth `state`.
22
+ * @param bytes - entropy length.
23
+ * @returns base64url-encoded random bytes.
24
+ */
25
+ export declare function randomToken(bytes?: number): string;
26
+ /**
27
+ * Mint lowercase-hex random bytes (for Grok's `nonce` parameter).
28
+ * @param bytes - entropy length; the hex string is twice as long.
29
+ * @returns hex-encoded random bytes.
30
+ */
31
+ export declare function randomHex(bytes?: number): string;
@@ -0,0 +1,35 @@
1
+ /** PKCE (RFC 7636) and random-token helpers for the OAuth login flows. */
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ /**
4
+ * Base64url-encode without padding.
5
+ * @param buffer - raw bytes.
6
+ * @returns the RFC 4648 §5 encoding.
7
+ */
8
+ export function base64url(buffer) {
9
+ return buffer.toString('base64url');
10
+ }
11
+ /**
12
+ * Mint a fresh PKCE pair (32-byte verifier, S256 challenge).
13
+ * @returns the pair for one authorization attempt.
14
+ */
15
+ export function createPkce() {
16
+ const verifier = base64url(randomBytes(32));
17
+ const challenge = base64url(createHash('sha256').update(verifier).digest());
18
+ return { verifier, challenge };
19
+ }
20
+ /**
21
+ * Mint a URL-safe random token (default 16 bytes) for OAuth `state`.
22
+ * @param bytes - entropy length.
23
+ * @returns base64url-encoded random bytes.
24
+ */
25
+ export function randomToken(bytes = 16) {
26
+ return base64url(randomBytes(bytes));
27
+ }
28
+ /**
29
+ * Mint lowercase-hex random bytes (for Grok's `nonce` parameter).
30
+ * @param bytes - entropy length; the hex string is twice as long.
31
+ * @returns hex-encoded random bytes.
32
+ */
33
+ export function randomHex(bytes = 8) {
34
+ return randomBytes(bytes).toString('hex');
35
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The `/subscriptions-auth` host RPC channel the web Settings page drives. The
3
+ * channel is registered only when a host `connection` service exists (the web
4
+ * profile); headless compositions load the plugin without it. All business
5
+ * outcomes are returned as RpcResult values; handlers never throw.
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis';
8
+ import { type ProviderId } from './store.js';
9
+ /** The RPC channel this plugin registers on the host connection. */
10
+ export declare const SUBSCRIPTIONS_AUTH_CHANNEL = "/subscriptions-auth";
11
+ /** Login state of one provider, as rendered by the Settings page. */
12
+ export interface ProviderStatus {
13
+ /** Whether a session exists in the store. */
14
+ loggedIn: boolean;
15
+ /** Whether a login attempt is currently waiting for its code. */
16
+ busy: boolean;
17
+ /** Epoch milliseconds at which the stored access token expires. */
18
+ expiresAt?: number;
19
+ /** Account email or account id, when known. */
20
+ account?: string;
21
+ /** Subscription detail (plan) or the last login error. */
22
+ detail?: string;
23
+ }
24
+ /** Provider-agnostic auth operations the RPC handler delegates to. */
25
+ export interface AuthController {
26
+ /** Current status of one provider. */
27
+ status(provider: ProviderId): Promise<ProviderStatus>;
28
+ /**
29
+ * Start a background login attempt.
30
+ * @returns the authorize URL for the user's browser.
31
+ * @throws when an attempt is already running for this provider.
32
+ */
33
+ login(provider: ProviderId): Promise<{
34
+ authorizeUrl: string;
35
+ }>;
36
+ /**
37
+ * Feed a pasted callback URL or bare code into the pending attempt.
38
+ * @throws when no attempt is pending or the input is unusable.
39
+ */
40
+ manual(provider: ProviderId, input: string): Promise<void>;
41
+ /** Abort the pending attempt; a no-op when none is pending. */
42
+ cancel(provider: ProviderId): Promise<void>;
43
+ /** Delete the stored session. */
44
+ logout(provider: ProviderId): Promise<void>;
45
+ }
46
+ /**
47
+ * Register the `/subscriptions-auth` RPC channel when a host connection exists.
48
+ * @param ctx - the plugin context (headless profiles have no `connection`).
49
+ * @param controller - the auth operations backing the endpoints.
50
+ */
51
+ export declare function registerAuthRpc(ctx: Context, controller: AuthController): void;