dsh-plugin-subscriptions 0.5.2 → 0.5.3

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 (41) hide show
  1. package/README.md +36 -1
  2. package/README.zh.md +36 -1
  3. package/lib/auth/rpc.d.ts +29 -12
  4. package/lib/auth/rpc.js +29 -6
  5. package/lib/auth/store.d.ts +75 -17
  6. package/lib/auth/store.js +148 -27
  7. package/lib/client/SubscriptionsSection.d.ts +9 -3
  8. package/lib/client/SubscriptionsSection.js +93 -65
  9. package/lib/client/locales.d.ts +18 -10
  10. package/lib/client/locales.js +18 -10
  11. package/lib/client.js +250 -127
  12. package/lib/client.js.map +1 -1
  13. package/lib/index.d.ts +21 -0
  14. package/lib/index.js +1482 -168
  15. package/lib/providers/accounts.d.ts +102 -0
  16. package/lib/providers/accounts.js +123 -0
  17. package/lib/providers/claude.d.ts +22 -4
  18. package/lib/providers/claude.js +91 -11
  19. package/lib/providers/codex.d.ts +24 -3
  20. package/lib/providers/codex.js +116 -17
  21. package/lib/providers/common.d.ts +17 -0
  22. package/lib/providers/common.js +67 -3
  23. package/lib/providers/copilot.d.ts +22 -3
  24. package/lib/providers/copilot.js +91 -12
  25. package/lib/providers/grok.d.ts +24 -4
  26. package/lib/providers/grok.js +100 -14
  27. package/lib/providers/pool-family.d.ts +56 -0
  28. package/lib/providers/pool-family.js +45 -0
  29. package/lib/providers/pool-health.d.ts +74 -0
  30. package/lib/providers/pool-health.js +148 -0
  31. package/lib/providers/pool-usage.d.ts +57 -0
  32. package/lib/providers/pool-usage.js +130 -0
  33. package/lib/providers/pool.d.ts +107 -0
  34. package/lib/providers/pool.js +371 -0
  35. package/lib/tools/image-generate.d.ts +3 -3
  36. package/lib/tools/image-generate.js +2 -1
  37. package/lib/tools/video-generate.d.ts +2 -2
  38. package/lib/tools/video-generate.js +2 -1
  39. package/lib/tools/x-search.d.ts +2 -2
  40. package/lib/tools/x-search.js +2 -1
  41. package/package.json +1 -1
package/README.md CHANGED
@@ -111,6 +111,10 @@ Either way, restart `dsh web` afterwards so the new version loads.
111
111
 
112
112
  Not logged in? The provider stays out of the picker, and requests fail with `MISSING_CREDENTIAL` pointing at the Settings page; nothing else breaks.
113
113
 
114
+ ### Multiple accounts
115
+
116
+ Every provider accepts several accounts: once one is connected, the card grows an **Add account** button (Claude offers **Browser authorization** and **Import Claude Code** separately). Accounts are keyed by their identity (email / login) — re-logging the same account updates it in place, a different account appends. Browser authorization signs in whichever account the browser currently uses, so switch accounts there first (or use an incognito window with the manual code) to add a different one. The ★ default account serves the direct provider routes; pool routes use every account. A Claude account imported from Claude Code stays synced with the CLI's credential store; OAuth-added Claude accounts refresh standalone so several accounts never fight over the Keychain entry.
117
+
114
118
  ## Config
115
119
 
116
120
  ```yaml
@@ -132,6 +136,37 @@ catalog does not know would otherwise default to `/chat/completions`, which
132
136
  responses-only families (gpt-5.5/5.6, …) reject. Pinning `chat-completions` also opts
133
137
  out of the tools+effort auto-reroute described above.
134
138
 
139
+ ## Model pools
140
+
141
+ When a provider has **two or more logged-in accounts**, the picker shows the **union** of every account's catalog (duplicates dropped). Pick `claude-sonnet-5` under Claude (or `gpt-5.4` under ChatGPT) as usual — there is no extra pool group and no new model id.
142
+
143
+ - **Shared models.** A model listed by ≥2 accounts failovers between them (sticky, quota-aware). Each account is discovered separately, so a Plus login is not asked to serve a Pro-only model.
144
+ - **Account-only models.** A model listed by only one account is sent to that account. It still appears in the picker even if that account is not the default.
145
+ - **Explicit account lists (`families`).** Replace the auto member list for one catalog model (same provider only; cross-provider members are ignored). Pin `account` or omit it for the default.
146
+ - **Tier extras (`tiers`, optional).** Extra picker rows with heterogeneous fallbacks, listed under the first member's provider. Not created automatically.
147
+
148
+ Selection is sticky per session (prompt caches survive) with two strategies: `priority` (first healthy member wins) and `quota_aware` (the default — each member is scored by its required burn rate, `remaining quota / time until window reset`, so a window about to reset with plenty left gets spent instead of wasted; the sticky member holds until a challenger out-scores it by `switchMargin`). Members past 95% on any usage window are gated out; failures fail over before the first stream chunk with cooldowns (`retry-after` when the provider sends one) — quota and auth failures cool the whole account down (its quota is account-level; Claude's model-scoped lanes cool per member), transient server failures cool only the failing member. Copilot exposes no usage telemetry, so it scores zero and naturally serves as the fallback of last resort.
149
+
150
+ ```yaml
151
+ - id: llm-subscriptions
152
+ name: dsh-plugin-subscriptions
153
+ config:
154
+ pool:
155
+ enabled: true # default; needs ≥2 accounts of one provider
156
+ strategy: quota_aware # or priority
157
+ switchMargin: 2 # hysteresis factor for quota_aware
158
+ autoAccounts: true # pool each catalog model across that provider's accounts
159
+ families: # explicit account list for one catalog model (same provider)
160
+ claude-sonnet-5:
161
+ - { provider: claude, model: claude-sonnet-5 } # default account
162
+ - { provider: claude, account: bob@example.com, model: claude-sonnet-5 }
163
+ tiers: # optional extra picker rows
164
+ smart:
165
+ - { provider: claude, model: claude-sonnet-5 }
166
+ - { provider: codex, model: gpt-5.6-sol }
167
+ - { provider: grok, model: grok-4.6 }
168
+ ```
169
+
135
170
  ## Proxy
136
171
 
137
172
  Every subscription request — token exchanges, model-API streams, usage lookups, model discovery, and the `x_search` / `image_generate` / `video_generate` tools — can be routed through an HTTP(S) proxy. Configure it in **Settings → Subscriptions → Proxy → Configure…**: enable the flag, enter the proxy URL (`http://127.0.0.1:7890`), optional username/password, and an optional comma-separated bypass list of hostnames that stay direct (`127.0.0.1`, `localhost`, `*.example.com`). The password is stored in `~/.dsh/plugins/subscriptions/proxy.json` (mode 0600) and is never returned to the browser. A "Test" button probes one endpoint through the current configuration and shows the HTTP status/latency.
@@ -154,7 +189,7 @@ After `pnpm build`, restart `dsh web` to pick up changes.
154
189
 
155
190
  - `src/index.ts` — plugin entry: config schema, adapter registration, auth-change re-announce, RPC wiring
156
191
  - `src/auth/` — PKCE/JWT helpers, token store, OAuth flow engine (temp loopback callback server), Claude Code credential reader (Keychain/file), `/subscriptions-auth` RPC channel
157
- - `src/providers/` — per-provider OAuth constants/exchange/refresh + `LlmAdapter`s
192
+ - `src/providers/` — per-provider OAuth constants/exchange/refresh + `LlmAdapter`s, multi-account token plumbing (`accounts.ts`), and the pool (`pool.ts` + `pool-health.ts` / `pool-usage.ts` / `pool-family.ts`)
158
193
  - `src/translate/` — dsh `Message[]` ⟷ OpenAI Responses / Anthropic Messages wire formats, SSE → `StreamChunk`
159
194
  - `src/tools/` — `x_search`, `image_generate`, and `video_generate`
160
195
  - `src/client/` — the Settings → Subscriptions page (browser half, zh/en, theme-token aware)
package/README.zh.md CHANGED
@@ -111,6 +111,10 @@ GitHub 安装的:重新执行一遍 `add github:V1ki/dsh-plugin-subscriptions`
111
111
 
112
112
  未登录时:该 provider 不出现在选择器里;直接请求会报 `MISSING_CREDENTIAL` 并提示去设置页登录,不影响其他功能。
113
113
 
114
+ ### 多账号
115
+
116
+ 每个 provider 可以登录多个账号:连上第一个之后,卡片会出现「添加账号」按钮(Claude 拆分为「浏览器授权」和「导入 Claude Code」两种)。账号按身份(邮箱/用户名)归档——重复登录同一账号是覆盖更新,不同账号才是新增。浏览器授权以浏览器当前登录的账号为准,要添加不同账号请先在浏览器切换账号,或用无痕窗口走手动授权码。★ 默认账号服务直连路由;池路由会使用所有账号。从 Claude Code 导入的 Claude 账号会与 CLI 的凭据存储保持同步;OAuth 添加的 Claude 账号独立刷新,多个账号不会互相覆盖 Keychain。
117
+
114
118
  ## 配置
115
119
 
116
120
  ```yaml
@@ -131,6 +135,37 @@ GitHub 安装的:重新执行一遍 `add github:V1ki/dsh-plugin-subscriptions`
131
135
  responses-only 系列(gpt-5.5/5.6 等)会拒绝该端点。固定为 `chat-completions` 也会退出上文所述
132
136
  tools+effort 的自动改道。
133
137
 
138
+ ## 模型池
139
+
140
+ 同一订阅下登录了**两个及以上账号**时,选择器显示该 provider **所有账号目录的并集**(按模型 id 去重)。照常在 Claude 组选 `claude-sonnet-5`、在 ChatGPT 组选 `gpt-5.4`——不会多出一个池分组,也不会换 model id。
141
+
142
+ - **共有模型**:至少两个账号的目录都列出的模型,在这些账号之间 failover(粘性、可按配额调度)。每个账号各自做一次目录发现,Plus 不会被拿去打 Pro 才有的模型。
143
+ - **单账号模型**:只有一个账号目录里有的模型,请求就打到那个账号。即使它不是默认账号,选择器里也会出现。
144
+ - **显式账号列表(`families`)**:覆盖某个目录模型的自动成员(仅同一 provider;跨 provider 的成员会被忽略)。可钉 `account`,省略则用默认账号。
145
+ - **档位额外项(`tiers`,可选)**:额外的选择器条目,failover 可以跨模型;出现在首个成员所在的 provider 分组。不会自动创建。
146
+
147
+ 成员选择按会话粘性(prompt 缓存不失效),两种策略:`priority`(按顺序取第一个健康成员)和 `quota_aware`(默认——按"必需消耗速率 = 剩余配额 / 距重置时间"给成员打分,快重置且剩余多的窗口优先被用掉而不是浪费;粘性成员除非被挑战者以 `switchMargin` 倍分差击败否则不换)。任一用量窗口超过 95% 的成员会被硬门槛挡下;首个流式 chunk 之前的失败会记冷却并切换下一家(provider 给了 `retry-after` 就用它)——配额与认证类失败按整个账号冷却(配额是账号级的;Claude 的分模型窗口则只冷却出错成员),瞬时服务端失败只冷却出错成员。Copilot 没有用量接口,恒为 0 分,自然充当最后的保底。
148
+
149
+ ```yaml
150
+ - id: llm-subscriptions
151
+ name: dsh-plugin-subscriptions
152
+ config:
153
+ pool:
154
+ enabled: true # 默认开;需同一 provider ≥2 个账号
155
+ strategy: quota_aware # 或 priority
156
+ switchMargin: 2 # quota_aware 的滞后切换倍率
157
+ autoAccounts: true # 把该 provider 各账号自动池到每个目录模型
158
+ families: # 某个目录模型的显式账号列表(同一 provider)
159
+ claude-sonnet-5:
160
+ - { provider: claude, model: claude-sonnet-5 } # 默认账号
161
+ - { provider: claude, account: bob@example.com, model: claude-sonnet-5 }
162
+ tiers: # 可选的额外选择器条目
163
+ smart:
164
+ - { provider: claude, model: claude-sonnet-5 }
165
+ - { provider: codex, model: gpt-5.6-sol }
166
+ - { provider: grok, model: grok-4.6 }
167
+ ```
168
+
134
169
  ## 代理
135
170
 
136
171
  所有订阅相关请求 —— token 交换、模型 API 流式调用、用量查询、模型目录发现,以及 `x_search` / `image_generate` / `video_generate` 工具 —— 都可以通过 HTTP(S) 代理发出。在 **设置 → 订阅 → 代理 → 配置…** 中设置:勾选启用,填写代理地址(`http://127.0.0.1:7890`)、可选用户名/密码,以及可选的逗号分隔绕过列表(保持直连的主机名,如 `127.0.0.1`、`localhost`、`*.example.com`)。密码保存在 `~/.dsh/plugins/subscriptions/proxy.json`(权限 0600),不会回传给浏览器;「测试」按钮会用当前配置探测一次端点,显示 HTTP 状态码与耗时。
@@ -153,7 +188,7 @@ pnpm test # 编译后跑 node --test 单测
153
188
 
154
189
  - `src/index.ts` —— 插件入口:配置 schema、adapter 注册、登录态变更通告、RPC 接线
155
190
  - `src/auth/` —— PKCE/JWT 工具、token 存储、OAuth 流程引擎(临时本地回调服务)、Claude Code 凭据读取器(Keychain/文件)、`/subscriptions-auth` RPC 通道
156
- - `src/providers/` —— 各 provider 的 OAuth 常量/换发/刷新 + `LlmAdapter` 实现
191
+ - `src/providers/` —— 各 provider 的 OAuth 常量/换发/刷新 + `LlmAdapter` 实现,多账号 token 管理(`accounts.ts`),以及模型池(`pool.ts` + `pool-health.ts` / `pool-usage.ts` / `pool-family.ts`)
157
192
  - `src/translate/` —— dsh `Message[]` 与 OpenAI Responses / Anthropic Messages 格式互转,SSE → `StreamChunk`
158
193
  - `src/tools/` —— `x_search`、`image_generate` 与 `video_generate`
159
194
  - `src/client/` —— 设置 → 订阅页面(浏览器面,中英文,跟随明暗主题)
package/lib/auth/rpc.d.ts CHANGED
@@ -37,19 +37,30 @@ export interface SpeedController {
37
37
  /** Set one session's speed tier. */
38
38
  setSpeed(sessionId: string, tier: SpeedTier): Promise<void>;
39
39
  }
40
+ /** One logged-in account, as rendered by the Settings page. */
41
+ export interface AccountStatus {
42
+ /** Stable account key (store identity). */
43
+ key: string;
44
+ /** Display identity (email / login), when known. */
45
+ account?: string;
46
+ /** Epoch milliseconds at which the stored access token expires. */
47
+ expiresAt?: number;
48
+ /** Plan name the session carries (codex planType / claude subscriptionType), when known. */
49
+ plan?: string;
50
+ /** Whether direct (non-pool) routes serve this account. */
51
+ isDefault: boolean;
52
+ }
40
53
  /** Login state of one provider, as rendered by the Settings page. */
41
54
  export interface ProviderStatus {
42
- /** Whether a session exists in the store. */
43
- loggedIn: boolean;
44
55
  /** Whether a login attempt is currently waiting for its code. */
45
56
  busy: boolean;
46
- /** Epoch milliseconds at which the stored access token expires. */
47
- expiresAt?: number;
48
- /** Account email or account id, when known. */
49
- account?: string;
50
- /** Subscription detail (plan) or the last login error. */
57
+ /** Logged-in accounts, default first. */
58
+ accounts: AccountStatus[];
59
+ /** The last login error, shown until the next success. */
51
60
  detail?: string;
52
61
  }
62
+ /** How a Claude login should acquire credentials (other providers ignore it). */
63
+ export type LoginMethod = 'oauth' | 'keychain';
53
64
  /** Proxy config operations behind the `proxyGet/proxySet/proxyTest` endpoints. */
54
65
  export interface ProxyConfigController {
55
66
  /** Current proxy configuration (secrets omitted). */
@@ -68,11 +79,15 @@ export interface AuthController {
68
79
  status(provider: ProviderId): Promise<ProviderStatus>;
69
80
  /**
70
81
  * Start a background login attempt.
82
+ * @param provider - the provider route.
83
+ * @param method - Claude only: force the OAuth browser flow or the Claude
84
+ * Code credential import; omitted keeps the auto behavior (import when
85
+ * available, else OAuth).
71
86
  * @returns the authorize URL for the user's browser; device-flow providers
72
87
  * (copilot) also return the `userCode` the user types at that URL.
73
88
  * @throws when an attempt is already running for this provider.
74
89
  */
75
- login(provider: ProviderId): Promise<{
90
+ login(provider: ProviderId, method?: LoginMethod): Promise<{
76
91
  authorizeUrl: string;
77
92
  userCode?: string;
78
93
  }>;
@@ -83,15 +98,17 @@ export interface AuthController {
83
98
  manual(provider: ProviderId, input: string): Promise<void>;
84
99
  /** Abort the pending attempt; a no-op when none is pending. */
85
100
  cancel(provider: ProviderId): Promise<void>;
86
- /** Delete the stored session. */
87
- logout(provider: ProviderId): Promise<void>;
101
+ /** Delete one account's stored session. */
102
+ logout(provider: ProviderId, account: string): Promise<void>;
103
+ /** Pin the account direct (non-pool) routes serve. */
104
+ setDefault(provider: ProviderId, account: string): Promise<void>;
88
105
  /**
89
- * Current subscription usage of one provider.
106
+ * Current subscription usage of one account.
90
107
  * @param signal - caller cancellation from the RPC transport.
91
108
  * @returns `{ supported: false }` when the provider has no usage endpoint.
92
109
  * @throws when logged out or the usage lookup fails.
93
110
  */
94
- usage(provider: ProviderId, signal: AbortSignal): Promise<ProviderUsage>;
111
+ usage(provider: ProviderId, account: string, signal: AbortSignal): Promise<ProviderUsage>;
95
112
  /**
96
113
  * Read one image attachment's bytes for inline display.
97
114
  * @param ref - the full durable reference (`readImage` verifies against it).
package/lib/auth/rpc.js CHANGED
@@ -42,6 +42,18 @@ function readString(payload, field) {
42
42
  }
43
43
  return value;
44
44
  }
45
+ /** Validate the optional Claude login method. */
46
+ function readLoginMethod(payload, provider) {
47
+ const method = payload.method;
48
+ if (method === undefined)
49
+ return undefined;
50
+ if (provider !== 'claude')
51
+ throw new BadRequest('payload.method is only valid for claude');
52
+ if (method !== 'oauth' && method !== 'keychain') {
53
+ throw new BadRequest('payload.method must be "oauth" or "keychain"');
54
+ }
55
+ return method;
56
+ }
45
57
  /** Validate the `setSpeed` endpoint's tier. */
46
58
  function readSpeedTier(payload) {
47
59
  const tier = payload.tier;
@@ -190,8 +202,10 @@ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
190
202
  const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
191
203
  return ok({ providers: Object.fromEntries(entries) });
192
204
  }
193
- case 'login':
194
- return ok(await controller.login(readProvider(payload)));
205
+ case 'login': {
206
+ const provider = readProvider(payload);
207
+ return ok(await controller.login(provider, readLoginMethod(payload, provider)));
208
+ }
195
209
  case 'manual': {
196
210
  const provider = readProvider(payload);
197
211
  await controller.manual(provider, readString(payload, 'input'));
@@ -200,11 +214,20 @@ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
200
214
  case 'cancel':
201
215
  await controller.cancel(readProvider(payload));
202
216
  return ok({ ok: true });
203
- case 'logout':
204
- await controller.logout(readProvider(payload));
217
+ case 'logout': {
218
+ const provider = readProvider(payload);
219
+ await controller.logout(provider, readString(payload, 'account'));
205
220
  return ok({ ok: true });
206
- case 'usage':
207
- return ok(await controller.usage(readProvider(payload), signal));
221
+ }
222
+ case 'setDefault': {
223
+ const provider = readProvider(payload);
224
+ await controller.setDefault(provider, readString(payload, 'account'));
225
+ return ok({ ok: true });
226
+ }
227
+ case 'usage': {
228
+ const provider = readProvider(payload);
229
+ return ok(await controller.usage(provider, readString(payload, 'account'), signal));
230
+ }
208
231
  case 'image':
209
232
  return ok(await controller.readImage(readImageRef(payload), signal));
210
233
  case 'video':
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * On-disk OAuth session store at `~/.dsh/plugins/subscriptions/auth.json`.
3
3
  *
4
- * The file is a JSON object keyed by provider id. Writes are atomic
5
- * (tmp file + rename) with mode 0600 because they carry bearer tokens.
6
- * Session shapes live here (not in the provider modules) because this file
7
- * owns the durable format.
4
+ * The file is a JSON object keyed by provider id, each entry holding that
5
+ * provider's ACCOUNTS: a map of account key session plus the default
6
+ * account's key. Writes are atomic (tmp file + rename) with mode 0600
7
+ * because they carry bearer tokens. Session shapes live here (not in the
8
+ * provider modules) because this file owns the durable format.
9
+ *
10
+ * Backward compatibility: entries written by single-account versions hold
11
+ * the session fields directly (no `accounts` wrapper); reads migrate them
12
+ * in memory, and the next write persists the new shape — existing logins
13
+ * survive the upgrade untouched.
8
14
  */
9
15
  /** Provider routes this plugin can serve. */
10
16
  export type ProviderId = 'codex' | 'claude' | 'grok' | 'copilot';
@@ -34,6 +40,11 @@ export interface ClaudeSession {
34
40
  scopes: string;
35
41
  emailAddress?: string;
36
42
  subscriptionType?: string;
43
+ /**
44
+ * True when this account was imported from Claude Code's own credential
45
+ * store (Keychain/file): only bound accounts sync refreshes back to it.
46
+ */
47
+ keychainBound?: boolean;
37
48
  }
38
49
  /** Stored Grok (X Premium / xAI) subscription session. */
39
50
  export interface GrokSession {
@@ -64,15 +75,42 @@ export interface CopilotSession {
64
75
  /** GitHub login name, for the status display. */
65
76
  account?: string;
66
77
  }
67
- /** The durable store shape: one optional session per provider. */
78
+ /** One provider's accounts: account key session, plus the default account. */
79
+ export interface ProviderAccounts<S> {
80
+ /** Key of the account direct (non-pool) routes serve; the first login wins. */
81
+ default?: string;
82
+ accounts: Record<string, S>;
83
+ }
84
+ /** The durable store shape: per provider, its accounts. */
68
85
  export interface SessionMap {
69
- codex?: CodexSession;
70
- claude?: ClaudeSession;
71
- grok?: GrokSession;
72
- copilot?: CopilotSession;
86
+ codex?: ProviderAccounts<CodexSession>;
87
+ claude?: ProviderAccounts<ClaudeSession>;
88
+ grok?: ProviderAccounts<GrokSession>;
89
+ copilot?: ProviderAccounts<CopilotSession>;
73
90
  }
74
91
  /** Any stored session, for provider-agnostic plumbing. */
75
92
  export type StoredSession = CodexSession | ClaudeSession | GrokSession | CopilotSession;
93
+ /** The session type one provider stores. */
94
+ export type SessionOf<K extends ProviderId> = NonNullable<SessionMap[K]>['accounts'][string];
95
+ /** One account entry as returned by {@link listAccounts} (default first). */
96
+ export interface AccountEntry<S> {
97
+ key: string;
98
+ session: S;
99
+ }
100
+ /**
101
+ * The stable identity of one session's account: codex keys on the always
102
+ * present `accountId` claim, the others on their display identity, falling
103
+ * back to a refresh-token hash for sessions stored before identity fields
104
+ * existed. Logging the same account in again lands on the same key, so a
105
+ * re-login updates in place instead of duplicating. (The hash fallback can
106
+ * miss that dedup once for a legacy session re-logged with a now-known
107
+ * identity — the duplicate is visible on the Settings page and can simply
108
+ * be logged out.)
109
+ * @param provider - the provider route.
110
+ * @param session - the session to key.
111
+ * @returns the account map key.
112
+ */
113
+ export declare function accountKeyOf(provider: ProviderId, session: StoredSession): string;
76
114
  /**
77
115
  * Absolute path of the auth store file.
78
116
  * @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
@@ -81,28 +119,48 @@ export declare function authFilePath(): string;
81
119
  /**
82
120
  * Read the whole store. A missing file is an empty store; malformed JSON or a
83
121
  * malformed entry throws, because silently discarding tokens would strand the
84
- * user without a diagnosis.
122
+ * user without a diagnosis. Single-account entries are migrated in memory;
123
+ * the next write persists the new shape.
85
124
  * @param path - store file path; defaults to {@link authFilePath}.
86
125
  * @returns the parsed session map.
87
126
  */
88
127
  export declare function loadStore(path?: string): Promise<SessionMap>;
89
128
  /**
90
- * Read one provider's session.
129
+ * List one provider's accounts, default first.
130
+ * @param provider - the provider route.
131
+ * @param path - store file path; defaults to {@link authFilePath}.
132
+ * @returns the account entries in stable order (empty when logged out).
133
+ */
134
+ export declare function listAccounts<K extends ProviderId>(provider: K, path?: string): Promise<AccountEntry<SessionOf<K>>[]>;
135
+ /**
136
+ * Read one account's session.
91
137
  * @param provider - the provider route.
138
+ * @param account - the account key; defaults to the provider's default account.
92
139
  * @param path - store file path; defaults to {@link authFilePath}.
93
- * @returns the stored session, or `undefined` when logged out.
140
+ * @returns the stored session, or `undefined` when absent.
94
141
  */
95
- export declare function getSession<K extends ProviderId>(provider: K, path?: string): Promise<SessionMap[K] | undefined>;
142
+ export declare function getAccountSession<K extends ProviderId>(provider: K, account?: string, path?: string): Promise<SessionOf<K> | undefined>;
96
143
  /**
97
- * Write one provider's session, preserving the others.
144
+ * Write one account's session, preserving the others. The first account of a
145
+ * provider becomes its default.
98
146
  * @param provider - the provider route.
147
+ * @param account - the account key (see {@link accountKeyOf}).
99
148
  * @param session - the fresh session from a login or refresh.
100
149
  * @param path - store file path; defaults to {@link authFilePath}.
101
150
  */
102
- export declare function saveSession<K extends ProviderId>(provider: K, session: NonNullable<SessionMap[K]>, path?: string): Promise<void>;
151
+ export declare function saveAccountSession<K extends ProviderId>(provider: K, account: string, session: SessionOf<K>, path?: string): Promise<void>;
152
+ /**
153
+ * Delete one account's session (logout). Deleting the default moves the badge
154
+ * to the next remaining account.
155
+ * @param provider - the provider route.
156
+ * @param account - the account key.
157
+ * @param path - store file path; defaults to {@link authFilePath}.
158
+ */
159
+ export declare function deleteAccountSession(provider: ProviderId, account: string, path?: string): Promise<void>;
103
160
  /**
104
- * Delete one provider's session (logout).
161
+ * Pin the account direct (non-pool) routes serve.
105
162
  * @param provider - the provider route.
163
+ * @param account - the account key; must exist.
106
164
  * @param path - store file path; defaults to {@link authFilePath}.
107
165
  */
108
- export declare function deleteSession(provider: ProviderId, path?: string): Promise<void>;
166
+ export declare function setDefaultAccount(provider: ProviderId, account: string, path?: string): Promise<void>;
package/lib/auth/store.js CHANGED
@@ -1,16 +1,52 @@
1
1
  /**
2
2
  * On-disk OAuth session store at `~/.dsh/plugins/subscriptions/auth.json`.
3
3
  *
4
- * The file is a JSON object keyed by provider id. Writes are atomic
5
- * (tmp file + rename) with mode 0600 because they carry bearer tokens.
6
- * Session shapes live here (not in the provider modules) because this file
7
- * owns the durable format.
4
+ * The file is a JSON object keyed by provider id, each entry holding that
5
+ * provider's ACCOUNTS: a map of account key session plus the default
6
+ * account's key. Writes are atomic (tmp file + rename) with mode 0600
7
+ * because they carry bearer tokens. Session shapes live here (not in the
8
+ * provider modules) because this file owns the durable format.
9
+ *
10
+ * Backward compatibility: entries written by single-account versions hold
11
+ * the session fields directly (no `accounts` wrapper); reads migrate them
12
+ * in memory, and the next write persists the new shape — existing logins
13
+ * survive the upgrade untouched.
8
14
  */
15
+ import { createHash } from 'node:crypto';
9
16
  import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
10
17
  import { dirname } from 'node:path';
11
18
  import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
12
19
  /** Every provider route, in display order. */
13
20
  export const PROVIDER_IDS = ['codex', 'claude', 'grok', 'copilot'];
21
+ /**
22
+ * The stable identity of one session's account: codex keys on the always
23
+ * present `accountId` claim, the others on their display identity, falling
24
+ * back to a refresh-token hash for sessions stored before identity fields
25
+ * existed. Logging the same account in again lands on the same key, so a
26
+ * re-login updates in place instead of duplicating. (The hash fallback can
27
+ * miss that dedup once for a legacy session re-logged with a now-known
28
+ * identity — the duplicate is visible on the Settings page and can simply
29
+ * be logged out.)
30
+ * @param provider - the provider route.
31
+ * @param session - the session to key.
32
+ * @returns the account map key.
33
+ */
34
+ export function accountKeyOf(provider, session) {
35
+ switch (provider) {
36
+ case 'codex':
37
+ return session.accountId;
38
+ case 'claude':
39
+ return session.emailAddress ?? tokenHash(session.refreshToken);
40
+ case 'grok':
41
+ return session.account ?? tokenHash(session.refreshToken);
42
+ case 'copilot':
43
+ return session.account ?? tokenHash(session.refreshToken);
44
+ }
45
+ }
46
+ /** Short stable hash for sessions without an identity field. */
47
+ function tokenHash(refreshToken) {
48
+ return `token-${createHash('sha256').update(refreshToken).digest('hex').slice(0, 16)}`;
49
+ }
14
50
  /**
15
51
  * Absolute path of the auth store file.
16
52
  * @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
@@ -22,22 +58,23 @@ export function authFilePath() {
22
58
  function legacyAuthFilePath() {
23
59
  return dshHomePath('plugins', 'router', 'auth.json');
24
60
  }
25
- /** Check that one durable entry carries the fields every session needs. */
26
- function assertSessionShape(provider, value) {
61
+ /** Check that one durable session carries the fields every session needs. */
62
+ function assertSessionShape(provider, account, value) {
27
63
  if (typeof value !== 'object' || value === null) {
28
- throw new Error(`subscriptions auth store: entry "${provider}" is not an object; fix or delete the store file`);
64
+ throw new Error(`subscriptions auth store: entry "${provider}/${account}" is not an object; fix or delete the store file`);
29
65
  }
30
66
  const entry = value;
31
67
  if (typeof entry.accessToken !== 'string' || entry.accessToken.length === 0
32
68
  || typeof entry.refreshToken !== 'string' || entry.refreshToken.length === 0
33
69
  || typeof entry.expiresAt !== 'number' || !Number.isFinite(entry.expiresAt)) {
34
- throw new Error(`subscriptions auth store: entry "${provider}" is missing accessToken/refreshToken/expiresAt; fix or delete the store file`);
70
+ throw new Error(`subscriptions auth store: entry "${provider}/${account}" is missing accessToken/refreshToken/expiresAt; fix or delete the store file`);
35
71
  }
36
72
  }
37
73
  /**
38
74
  * Read the whole store. A missing file is an empty store; malformed JSON or a
39
75
  * malformed entry throws, because silently discarding tokens would strand the
40
- * user without a diagnosis.
76
+ * user without a diagnosis. Single-account entries are migrated in memory;
77
+ * the next write persists the new shape.
41
78
  * @param path - store file path; defaults to {@link authFilePath}.
42
79
  * @returns the parsed session map.
43
80
  */
@@ -67,7 +104,7 @@ export async function loadStore(path = authFilePath()) {
67
104
  }
68
105
  return parseStore(text, path);
69
106
  }
70
- /** Parse and validate store JSON read from `path`. */
107
+ /** Parse, validate, and migrate store JSON read from `path`. */
71
108
  function parseStore(text, path) {
72
109
  let parsed;
73
110
  try {
@@ -79,11 +116,36 @@ function parseStore(text, path) {
79
116
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
80
117
  throw new Error(`subscriptions auth store at ${path} must be a JSON object keyed by provider; fix or delete the file`);
81
118
  }
82
- const store = parsed;
119
+ const raw = parsed;
120
+ const store = {};
83
121
  for (const provider of PROVIDER_IDS) {
84
- const entry = store[provider];
85
- if (entry !== undefined)
86
- assertSessionShape(provider, entry);
122
+ const entry = raw[provider];
123
+ if (entry === undefined)
124
+ continue;
125
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
126
+ throw new Error(`subscriptions auth store: entry "${provider}" is not an object; fix or delete the store file`);
127
+ }
128
+ const record = entry;
129
+ if (typeof record.accessToken === 'string') {
130
+ // Single-account format: wrap the bare session, preserving every field.
131
+ assertSessionShape(provider, '(legacy)', record);
132
+ const session = record;
133
+ const key = accountKeyOf(provider, session);
134
+ store[provider] = { default: key, accounts: { [key]: session } };
135
+ continue;
136
+ }
137
+ const accounts = record.accounts;
138
+ if (typeof accounts !== 'object' || accounts === null || Array.isArray(accounts)) {
139
+ throw new Error(`subscriptions auth store: entry "${provider}" has no accounts map; fix or delete the store file`);
140
+ }
141
+ if (record.default !== undefined && typeof record.default !== 'string') {
142
+ throw new Error(`subscriptions auth store: entry "${provider}" default is not a string; fix or delete the store file`);
143
+ }
144
+ for (const [account, session] of Object.entries(accounts)) {
145
+ assertSessionShape(provider, account, session);
146
+ }
147
+ ;
148
+ store[provider] = record;
87
149
  }
88
150
  return store;
89
151
  }
@@ -106,8 +168,8 @@ async function writeStore(store, path) {
106
168
  /**
107
169
  * One write chain per store path. Every mutation is a read-modify-write of a
108
170
  * single JSON file, and the plugin has several independent writers — a login,
109
- * a logout, and one token refresh per provider adapter, each on its own
110
- * schedule. Overlapping them unserialized costs whichever provider read the
171
+ * a logout, and one token refresh per provider account, each on its own
172
+ * schedule. Overlapping them unserialized costs whichever account read the
111
173
  * store first its entry.
112
174
  *
113
175
  * A chain is dropped once nothing is queued behind it, so the map holds an
@@ -136,38 +198,97 @@ async function serialize(path, action) {
136
198
  }
137
199
  }
138
200
  /**
139
- * Read one provider's session.
201
+ * List one provider's accounts, default first.
140
202
  * @param provider - the provider route.
141
203
  * @param path - store file path; defaults to {@link authFilePath}.
142
- * @returns the stored session, or `undefined` when logged out.
204
+ * @returns the account entries in stable order (empty when logged out).
143
205
  */
144
- export async function getSession(provider, path = authFilePath()) {
145
- return (await loadStore(path))[provider];
206
+ export async function listAccounts(provider, path = authFilePath()) {
207
+ const entry = (await loadStore(path))[provider];
208
+ if (entry === undefined)
209
+ return [];
210
+ const accounts = Object.entries(entry.accounts).map(([key, session]) => ({ key, session }));
211
+ accounts.sort((a, b) => Number(b.key === entry.default) - Number(a.key === entry.default));
212
+ return accounts;
146
213
  }
147
214
  /**
148
- * Write one provider's session, preserving the others.
215
+ * Read one account's session.
149
216
  * @param provider - the provider route.
217
+ * @param account - the account key; defaults to the provider's default account.
218
+ * @param path - store file path; defaults to {@link authFilePath}.
219
+ * @returns the stored session, or `undefined` when absent.
220
+ */
221
+ export async function getAccountSession(provider, account, path = authFilePath()) {
222
+ const entry = (await loadStore(path))[provider];
223
+ if (entry === undefined)
224
+ return undefined;
225
+ const key = account ?? entry.default;
226
+ if (key === undefined)
227
+ return undefined;
228
+ return entry.accounts[key];
229
+ }
230
+ /**
231
+ * Write one account's session, preserving the others. The first account of a
232
+ * provider becomes its default.
233
+ * @param provider - the provider route.
234
+ * @param account - the account key (see {@link accountKeyOf}).
150
235
  * @param session - the fresh session from a login or refresh.
151
236
  * @param path - store file path; defaults to {@link authFilePath}.
152
237
  */
153
- export async function saveSession(provider, session, path = authFilePath()) {
238
+ export async function saveAccountSession(provider, account, session, path = authFilePath()) {
154
239
  return serialize(path, async () => {
155
240
  const store = await loadStore(path);
156
- store[provider] = session;
241
+ const entry = store[provider];
242
+ store[provider] = {
243
+ default: entry?.default ?? account,
244
+ accounts: { ...entry?.accounts, [account]: session },
245
+ };
157
246
  await writeStore(store, path);
158
247
  });
159
248
  }
160
249
  /**
161
- * Delete one provider's session (logout).
250
+ * Delete one account's session (logout). Deleting the default moves the badge
251
+ * to the next remaining account.
162
252
  * @param provider - the provider route.
253
+ * @param account - the account key.
163
254
  * @param path - store file path; defaults to {@link authFilePath}.
164
255
  */
165
- export async function deleteSession(provider, path = authFilePath()) {
256
+ export async function deleteAccountSession(provider, account, path = authFilePath()) {
166
257
  return serialize(path, async () => {
167
258
  const store = await loadStore(path);
168
- if (store[provider] === undefined)
259
+ const entry = store[provider];
260
+ if (entry === undefined || !(account in entry.accounts))
169
261
  return;
170
- delete store[provider];
262
+ const accounts = { ...entry.accounts };
263
+ delete accounts[account];
264
+ if (Object.keys(accounts).length === 0) {
265
+ delete store[provider];
266
+ }
267
+ else {
268
+ ;
269
+ store[provider] = {
270
+ ...entry.default === account ? { default: Object.keys(accounts)[0] } : { default: entry.default },
271
+ accounts,
272
+ };
273
+ }
274
+ await writeStore(store, path);
275
+ });
276
+ }
277
+ /**
278
+ * Pin the account direct (non-pool) routes serve.
279
+ * @param provider - the provider route.
280
+ * @param account - the account key; must exist.
281
+ * @param path - store file path; defaults to {@link authFilePath}.
282
+ */
283
+ export async function setDefaultAccount(provider, account, path = authFilePath()) {
284
+ return serialize(path, async () => {
285
+ const store = await loadStore(path);
286
+ const entry = store[provider];
287
+ if (entry === undefined || !(account in entry.accounts)) {
288
+ throw new Error(`no ${provider} account "${account}" is logged in`);
289
+ }
290
+ ;
291
+ store[provider] = { ...entry, default: account };
171
292
  await writeStore(store, path);
172
293
  });
173
294
  }