dsh-plugin-subscription-usage 0.0.0-stage → 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 (167) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +38 -2
  3. package/cordis.patch.yml +5 -0
  4. package/icon.svg +9 -0
  5. package/lib/auth/claude-code-creds.d.ts +23 -0
  6. package/lib/auth/claude-code-creds.js +172 -0
  7. package/lib/auth/device-flow.d.ts +55 -0
  8. package/lib/auth/device-flow.js +177 -0
  9. package/lib/auth/jwt.d.ts +10 -0
  10. package/lib/auth/jwt.js +25 -0
  11. package/lib/auth/oauth-flow.d.ts +91 -0
  12. package/lib/auth/oauth-flow.js +227 -0
  13. package/lib/auth/pkce.d.ts +31 -0
  14. package/lib/auth/pkce.js +35 -0
  15. package/lib/auth/rpc.d.ts +233 -0
  16. package/lib/auth/rpc.js +501 -0
  17. package/lib/auth/store.d.ts +198 -0
  18. package/lib/auth/store.js +473 -0
  19. package/lib/client/Footer.d.ts +3 -0
  20. package/lib/client/Footer.js +73 -0
  21. package/lib/client/Gauge.d.ts +28 -0
  22. package/lib/client/Gauge.js +139 -0
  23. package/lib/client/ImageGallery.d.ts +54 -0
  24. package/lib/client/ImageGallery.js +112 -0
  25. package/lib/client/ImageGenerateToolview.d.ts +48 -0
  26. package/lib/client/ImageGenerateToolview.js +130 -0
  27. package/lib/client/Notice.d.ts +15 -0
  28. package/lib/client/Notice.js +93 -0
  29. package/lib/client/ProviderCard.d.ts +35 -0
  30. package/lib/client/ProviderCard.js +240 -0
  31. package/lib/client/SpeedSelect.d.ts +72 -0
  32. package/lib/client/SpeedSelect.js +214 -0
  33. package/lib/client/Usage.d.ts +7 -0
  34. package/lib/client/Usage.js +83 -0
  35. package/lib/client/VideoGenerateToolview.d.ts +42 -0
  36. package/lib/client/VideoGenerateToolview.js +172 -0
  37. package/lib/client/context.d.ts +16 -0
  38. package/lib/client/context.js +11 -0
  39. package/lib/client/fast-command.d.ts +2 -0
  40. package/lib/client/fast-command.js +4 -0
  41. package/lib/client/hooks.d.ts +2 -0
  42. package/lib/client/hooks.js +10 -0
  43. package/lib/client/index.d.ts +3 -0
  44. package/lib/client/index.js +113 -0
  45. package/lib/client/locales.d.ts +139 -0
  46. package/lib/client/locales.js +267 -0
  47. package/lib/client/meter.d.ts +91 -0
  48. package/lib/client/meter.js +196 -0
  49. package/lib/client/prefs.d.ts +51 -0
  50. package/lib/client/prefs.js +71 -0
  51. package/lib/client/rpc.d.ts +161 -0
  52. package/lib/client/rpc.js +31 -0
  53. package/lib/client/store.d.ts +67 -0
  54. package/lib/client/store.js +129 -0
  55. package/lib/client/styles.d.ts +7 -0
  56. package/lib/client/styles.js +176 -0
  57. package/lib/client.js +3933 -0
  58. package/lib/client.js.map +1 -0
  59. package/lib/compat.d.ts +55 -0
  60. package/lib/compat.js +20 -0
  61. package/lib/http.d.ts +134 -0
  62. package/lib/http.js +431 -0
  63. package/lib/index.d.ts +121 -0
  64. package/lib/index.js +13210 -0
  65. package/lib/json-text.d.ts +2 -0
  66. package/lib/json-text.js +4 -0
  67. package/lib/model-defaults.d.ts +23 -0
  68. package/lib/model-defaults.js +238 -0
  69. package/lib/model-speeds.d.ts +10 -0
  70. package/lib/model-speeds.js +48 -0
  71. package/lib/models-directory.d.ts +17 -0
  72. package/lib/models-directory.js +25 -0
  73. package/lib/provider-settings.d.ts +55 -0
  74. package/lib/provider-settings.js +215 -0
  75. package/lib/providers/account-preferences.d.ts +46 -0
  76. package/lib/providers/account-preferences.js +188 -0
  77. package/lib/providers/accounts.d.ts +113 -0
  78. package/lib/providers/accounts.js +142 -0
  79. package/lib/providers/antigravity-oauth-client.d.ts +7 -0
  80. package/lib/providers/antigravity-oauth-client.js +7 -0
  81. package/lib/providers/antigravity.d.ts +119 -0
  82. package/lib/providers/antigravity.js +552 -0
  83. package/lib/providers/catalog-refresh.d.ts +4 -0
  84. package/lib/providers/catalog-refresh.js +30 -0
  85. package/lib/providers/catalog-store.d.ts +47 -0
  86. package/lib/providers/catalog-store.js +283 -0
  87. package/lib/providers/claude-cli-version.d.ts +15 -0
  88. package/lib/providers/claude-cli-version.js +24 -0
  89. package/lib/providers/claude.d.ts +212 -0
  90. package/lib/providers/claude.js +726 -0
  91. package/lib/providers/codex-client-version.d.ts +8 -0
  92. package/lib/providers/codex-client-version.js +17 -0
  93. package/lib/providers/codex-search.d.ts +29 -0
  94. package/lib/providers/codex-search.js +198 -0
  95. package/lib/providers/codex.d.ts +253 -0
  96. package/lib/providers/codex.js +930 -0
  97. package/lib/providers/common.d.ts +415 -0
  98. package/lib/providers/common.js +679 -0
  99. package/lib/providers/copilot.d.ts +365 -0
  100. package/lib/providers/copilot.js +956 -0
  101. package/lib/providers/grok.d.ts +214 -0
  102. package/lib/providers/grok.js +750 -0
  103. package/lib/providers/image-pool.d.ts +30 -0
  104. package/lib/providers/image-pool.js +89 -0
  105. package/lib/providers/npm-cli-version.d.ts +56 -0
  106. package/lib/providers/npm-cli-version.js +98 -0
  107. package/lib/providers/pool-family.d.ts +56 -0
  108. package/lib/providers/pool-family.js +45 -0
  109. package/lib/providers/pool-health.d.ts +91 -0
  110. package/lib/providers/pool-health.js +164 -0
  111. package/lib/providers/pool-usage.d.ts +106 -0
  112. package/lib/providers/pool-usage.js +302 -0
  113. package/lib/providers/pool.d.ts +115 -0
  114. package/lib/providers/pool.js +461 -0
  115. package/lib/providers/rate-limit.d.ts +192 -0
  116. package/lib/providers/rate-limit.js +338 -0
  117. package/lib/providers/replay.d.ts +4 -0
  118. package/lib/providers/replay.js +37 -0
  119. package/lib/providers/reset-redemption.d.ts +20 -0
  120. package/lib/providers/reset-redemption.js +70 -0
  121. package/lib/tools/image-generate.d.ts +128 -0
  122. package/lib/tools/image-generate.js +438 -0
  123. package/lib/tools/registration.d.ts +15 -0
  124. package/lib/tools/registration.js +24 -0
  125. package/lib/tools/video-generate.d.ts +89 -0
  126. package/lib/tools/video-generate.js +257 -0
  127. package/lib/tools/x-search.d.ts +58 -0
  128. package/lib/tools/x-search.js +197 -0
  129. package/lib/translate/anthropic.d.ts +180 -0
  130. package/lib/translate/anthropic.js +534 -0
  131. package/lib/translate/antigravity-images.d.ts +11 -0
  132. package/lib/translate/antigravity-images.js +62 -0
  133. package/lib/translate/antigravity-schema.d.ts +4 -0
  134. package/lib/translate/antigravity-schema.js +145 -0
  135. package/lib/translate/antigravity-thinking.d.ts +5 -0
  136. package/lib/translate/antigravity-thinking.js +44 -0
  137. package/lib/translate/antigravity.d.ts +120 -0
  138. package/lib/translate/antigravity.js +412 -0
  139. package/lib/translate/chat-completions.d.ts +120 -0
  140. package/lib/translate/chat-completions.js +382 -0
  141. package/lib/translate/resolved.d.ts +98 -0
  142. package/lib/translate/resolved.js +186 -0
  143. package/lib/translate/responses.d.ts +192 -0
  144. package/lib/translate/responses.js +443 -0
  145. package/lib/translate/sse.d.ts +21 -0
  146. package/lib/translate/sse.js +62 -0
  147. package/lib/usage/headers.d.ts +21 -0
  148. package/lib/usage/headers.js +64 -0
  149. package/lib/usage/pace.d.ts +53 -0
  150. package/lib/usage/pace.js +48 -0
  151. package/lib/usage/service.d.ts +64 -0
  152. package/lib/usage/service.js +104 -0
  153. package/lib/usage-providers/common.d.ts +23 -0
  154. package/lib/usage-providers/common.js +28 -0
  155. package/lib/usage-providers/index.d.ts +42 -0
  156. package/lib/usage-providers/index.js +71 -0
  157. package/lib/usage-providers/keys.d.ts +11 -0
  158. package/lib/usage-providers/keys.js +52 -0
  159. package/lib/usage-providers/kimi.d.ts +4 -0
  160. package/lib/usage-providers/kimi.js +52 -0
  161. package/lib/usage-providers/ollama-cloud.d.ts +4 -0
  162. package/lib/usage-providers/ollama-cloud.js +42 -0
  163. package/lib/usage-providers/zai.d.ts +5 -0
  164. package/lib/usage-providers/zai.js +53 -0
  165. package/locale/en.json +6 -0
  166. package/locale/zh.json +6 -0
  167. package/package.json +115 -4
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 V1ki
4
+ Copyright (c) 2026 Parth Patel
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,39 @@
1
- # Temporary Holding Version
1
+ # dsh-plugin-subscription-usage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A DeepSeek Harness plugin that uses your ChatGPT (Codex), Claude, Grok, GitHub Copilot and Google Antigravity subscriptions as LLM providers, and shows how fast you are using each limit.
4
+
5
+ It replaces `dsh-plugin-subscriptions`. Both register the `codex` adapter, so enabling both fails with `DUPLICATE_ADAPTER`. Disable the other one first.
6
+
7
+ ## What you get
8
+
9
+ - **Models page.** Each subscription is a provider row where you sign in, manage accounts, choose models and enable tools.
10
+ - **Usage page.** Settings, Usage shows each limit with a pace forecast: how much is used, when it resets, and whether you will run out first. You can switch between Left and Used, turn pace warnings on or off, and pin providers to the composer.
11
+ - **Composer gauge.** The current model's provider, plus any you pinned, shows under the composer.
12
+ - **API key usage.** Z.ai, Ollama Cloud and Kimi Code usage is read with an API key you paste on the Usage page. These providers are usage-only. The plugin does not route requests to them.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ dsh plugin install dsh-plugin-subscription-usage
18
+ ```
19
+
20
+ Restart DSH, then open Settings, Models.
21
+
22
+ ## Privacy
23
+
24
+ - OAuth sessions are stored in `~/.dsh/plugins/subscriptions/auth.json`.
25
+ - API keys are stored in `~/.dsh/plugins/subscriptions/usage-keys.json`.
26
+ - Both files are readable only by you. Keys are sent only to the provider's own host and never to the browser page, which sees the last four characters.
27
+
28
+ ## Limits
29
+
30
+ - Copilot, Z.ai, Ollama Cloud and Kimi Code usage comes from endpoints the vendors do not document. They can change without notice, and the card then shows "Usage unavailable".
31
+ - A plain Moonshot key does not work for Kimi Code usage.
32
+
33
+ ## Credits
34
+
35
+ Built on [V1ki/dsh-plugin-subscriptions](https://github.com/V1ki/dsh-plugin-subscriptions) (MIT). The sign-in, provider, pool and translation code under `src/auth`, `src/providers`, `src/translate` and `src/tools` comes from it. The Usage page, key providers and the Models UI are new.
36
+
37
+ ## License
38
+
39
+ MIT
@@ -0,0 +1,5 @@
1
+ # Mounts the Host half when this bundle is installed into a profile. The
2
+ # Client half is discovered from `dsh.client` in package.json.
3
+ - insert:
4
+ - id: subscription-models
5
+ name: dsh-plugin-subscription-usage
package/icon.svg ADDED
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <rect width="64" height="64" rx="14" fill="#1f2a44"/>
3
+ <rect x="12" y="20" width="40" height="7" rx="3.5" fill="#33405e"/>
4
+ <rect x="12" y="20" width="26" height="7" rx="3.5" fill="#22c55e"/>
5
+ <rect x="31" y="16" width="3" height="15" rx="1.5" fill="#f9fafb"/>
6
+ <rect x="12" y="37" width="40" height="7" rx="3.5" fill="#33405e"/>
7
+ <rect x="12" y="37" width="32" height="7" rx="3.5" fill="#f59e0b"/>
8
+ <rect x="25" y="33" width="3" height="15" rx="1.5" fill="#f9fafb"/>
9
+ </svg>
@@ -0,0 +1,23 @@
1
+ import type { ClaudeSession } from './store.js';
2
+ /** Read the current Claude Code session from its source of truth: macOS Keychain, falling back to the credentials file. */
3
+ export declare function readClaudeCodeCredentials(): ClaudeSession | undefined;
4
+ /**
5
+ * Write a refreshed session back to Claude Code's own credential store, so
6
+ * the `claude` CLI and any other consumer of the same account see the token
7
+ * we just rotated. A stale-blob mismatch (something else rotated it first)
8
+ * is a no-op — the caller already has that other rotation via readClaudeCodeCredentials.
9
+ * @param next - the freshly refreshed session to persist.
10
+ * @param expectedPriorAccessToken - the access token this refresh started from.
11
+ * @returns whether the write-back succeeded.
12
+ */
13
+ export declare function writeBackClaudeCodeCredentials(next: ClaudeSession, expectedPriorAccessToken: string): boolean;
14
+ /**
15
+ * Refresh a Claude session, first checking whether Claude Code's own store
16
+ * already holds a fresher token (rotated by the `claude` CLI or another
17
+ * consumer) before hitting the OAuth endpoint ourselves — and writing our own
18
+ * refresh back to that store so every consumer of the account stays synced.
19
+ * @param session - the session TokenManager wants refreshed.
20
+ * @param doRefresh - the actual OAuth refresh-token grant (network call).
21
+ * @returns the freshest available session.
22
+ */
23
+ export declare function refreshClaudeSynced(session: ClaudeSession, doRefresh: (session: ClaudeSession) => Promise<ClaudeSession>): Promise<ClaudeSession>;
@@ -0,0 +1,172 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ const PRIMARY_SERVICE = 'Claude Code-credentials';
6
+ const DEFAULT_SCOPES = 'user:profile user:inference user:sessions:claude_code user:mcp_servers';
7
+ function toSession(data) {
8
+ // Empty-string tokens (seen from a corrupted Keychain item left by a Claude
9
+ // Code logout) pass the typeof gate but are useless and would poison the
10
+ // auth store — a single such entry fails every provider's status read.
11
+ if (typeof data.accessToken !== 'string' || data.accessToken.length === 0
12
+ || typeof data.refreshToken !== 'string' || data.refreshToken.length === 0
13
+ || typeof data.expiresAt !== 'number' || !Number.isFinite(data.expiresAt)) {
14
+ return undefined;
15
+ }
16
+ const scopes = Array.isArray(data.scopes) ? data.scopes.join(' ') : typeof data.scopes === 'string' ? data.scopes : DEFAULT_SCOPES;
17
+ return {
18
+ accessToken: data.accessToken,
19
+ refreshToken: data.refreshToken,
20
+ expiresAt: Math.trunc(data.expiresAt),
21
+ scopes,
22
+ ...typeof data.emailAddress === 'string' ? { emailAddress: data.emailAddress } : {},
23
+ ...typeof data.subscriptionType === 'string' ? { subscriptionType: data.subscriptionType } : {},
24
+ };
25
+ }
26
+ function parseBlob(raw) {
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(raw);
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ return toSession(parsed.claudeAiOauth ?? parsed);
35
+ }
36
+ function credentialsFilePath() {
37
+ return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), '.credentials.json');
38
+ }
39
+ function readKeychainRaw() {
40
+ try {
41
+ return execFileSync('/usr/bin/security', ['find-generic-password', '-s', PRIMARY_SERVICE, '-w'], {
42
+ timeout: 3000,
43
+ encoding: 'utf8',
44
+ stdio: ['pipe', 'pipe', 'pipe'],
45
+ }).trim();
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ function readFileRaw() {
52
+ try {
53
+ return readFileSync(credentialsFilePath(), 'utf8');
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ /** Read the current Claude Code session from its source of truth: macOS Keychain, falling back to the credentials file. */
60
+ export function readClaudeCodeCredentials() {
61
+ if (process.platform === 'darwin') {
62
+ const raw = readKeychainRaw();
63
+ const session = raw !== undefined ? parseBlob(raw) : undefined;
64
+ if (session)
65
+ return session;
66
+ }
67
+ const raw = readFileRaw();
68
+ return raw !== undefined ? parseBlob(raw) : undefined;
69
+ }
70
+ function blobMatches(raw, expectedAccessToken) {
71
+ return parseBlob(raw)?.accessToken === expectedAccessToken;
72
+ }
73
+ function getKeychainAccountName() {
74
+ try {
75
+ const output = execFileSync('/usr/bin/security', ['find-generic-password', '-s', PRIMARY_SERVICE], {
76
+ timeout: 2000,
77
+ encoding: 'utf8',
78
+ stdio: ['pipe', 'pipe', 'pipe'],
79
+ });
80
+ return /"acct"<blob>="([^"]*)"/.exec(output)?.[1];
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ /** Merge fresh tokens into an existing raw blob, preserving unrelated fields. */
87
+ function mergeIntoBlob(existingRaw, next) {
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(existingRaw);
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ const target = (parsed.claudeAiOauth ?? parsed);
96
+ target.accessToken = next.accessToken;
97
+ target.refreshToken = next.refreshToken;
98
+ target.expiresAt = next.expiresAt;
99
+ return JSON.stringify(parsed);
100
+ }
101
+ /**
102
+ * Write a refreshed session back to Claude Code's own credential store, so
103
+ * the `claude` CLI and any other consumer of the same account see the token
104
+ * we just rotated. A stale-blob mismatch (something else rotated it first)
105
+ * is a no-op — the caller already has that other rotation via readClaudeCodeCredentials.
106
+ * @param next - the freshly refreshed session to persist.
107
+ * @param expectedPriorAccessToken - the access token this refresh started from.
108
+ * @returns whether the write-back succeeded.
109
+ */
110
+ export function writeBackClaudeCodeCredentials(next, expectedPriorAccessToken) {
111
+ if (process.platform === 'darwin') {
112
+ const raw = readKeychainRaw();
113
+ if (raw === undefined || !blobMatches(raw, expectedPriorAccessToken))
114
+ return false;
115
+ const updated = mergeIntoBlob(raw, next);
116
+ if (updated === undefined)
117
+ return false;
118
+ const account = getKeychainAccountName() ?? PRIMARY_SERVICE;
119
+ try {
120
+ execFileSync('/usr/bin/security', ['add-generic-password', '-s', PRIMARY_SERVICE, '-a', account, '-w', updated, '-U'], {
121
+ timeout: 2000,
122
+ stdio: 'ignore',
123
+ });
124
+ return true;
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
130
+ const path = credentialsFilePath();
131
+ let raw;
132
+ try {
133
+ raw = readFileSync(path, 'utf8');
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ if (!blobMatches(raw, expectedPriorAccessToken))
139
+ return false;
140
+ const updated = mergeIntoBlob(raw, next);
141
+ if (updated === undefined)
142
+ return false;
143
+ try {
144
+ const dir = dirname(path);
145
+ if (!existsSync(dir))
146
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
147
+ writeFileSync(path, updated, { encoding: 'utf8', mode: 0o600 });
148
+ chmodSync(path, 0o600);
149
+ return true;
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
155
+ /**
156
+ * Refresh a Claude session, first checking whether Claude Code's own store
157
+ * already holds a fresher token (rotated by the `claude` CLI or another
158
+ * consumer) before hitting the OAuth endpoint ourselves — and writing our own
159
+ * refresh back to that store so every consumer of the account stays synced.
160
+ * @param session - the session TokenManager wants refreshed.
161
+ * @param doRefresh - the actual OAuth refresh-token grant (network call).
162
+ * @returns the freshest available session.
163
+ */
164
+ export async function refreshClaudeSynced(session, doRefresh) {
165
+ const fromSource = readClaudeCodeCredentials();
166
+ const base = fromSource !== undefined && fromSource.accessToken !== session.accessToken ? fromSource : session;
167
+ if (base.expiresAt > Date.now() + 60_000)
168
+ return base;
169
+ const next = await doRefresh(base);
170
+ writeBackClaudeCodeCredentials(next, base.accessToken);
171
+ return next;
172
+ }
@@ -0,0 +1,55 @@
1
+ /** Static per-provider device-flow facts. */
2
+ export interface DeviceFlowSpec {
3
+ /** OAuth App / GitHub App client id the device code is requested for. */
4
+ clientId: string;
5
+ /** Scope string requested at device-code time. */
6
+ scope: string;
7
+ /** Device-code endpoint (e.g. `https://github.com/login/device/code`). */
8
+ deviceCodeUrl: string;
9
+ /** Token polling endpoint (e.g. `https://github.com/login/oauth/access_token`). */
10
+ tokenUrl: string;
11
+ /** Fetch implementation (injectable for tests). */
12
+ fetchFn?: typeof fetch;
13
+ }
14
+ /** One in-flight device-flow login attempt. */
15
+ export interface DeviceAttempt {
16
+ /** URL the user opens to authorize (e.g. `https://github.com/login/device`). */
17
+ readonly verificationUrl: string;
18
+ /** Short code the user types at the verification URL. */
19
+ readonly userCode: string;
20
+ /**
21
+ * Poll until GitHub releases the access token.
22
+ * @returns the GitHub OAuth access token; rejects on timeout, denial, or cancel.
23
+ */
24
+ waitToken(): Promise<string>;
25
+ /** Abort the attempt; `waitToken` rejects with a cancellation error. */
26
+ cancel(): void;
27
+ }
28
+ /**
29
+ * Own the set of in-flight device-flow attempts, keyed by provider. One
30
+ * attempt per provider at a time; an attempt removes itself when it settles.
31
+ */
32
+ export declare class DeviceFlowManager {
33
+ private attempts;
34
+ /**
35
+ * Whether a device-flow attempt is running for one provider.
36
+ * @param provider - the provider route.
37
+ * @returns true while an attempt is polling.
38
+ */
39
+ isBusy(provider: string): boolean;
40
+ /**
41
+ * The pending attempt for one provider, when any.
42
+ * @param provider - the provider route.
43
+ * @returns the in-flight attempt, or `undefined`.
44
+ */
45
+ pending(provider: string): DeviceAttempt | undefined;
46
+ /**
47
+ * Start a device-flow attempt: request a device code, then poll the token
48
+ * endpoint in the background of `waitToken`.
49
+ * @param provider - the provider route (one attempt at a time).
50
+ * @param spec - static flow facts for this provider.
51
+ * @returns the live attempt; its `waitToken()` settles the login.
52
+ * @throws when an attempt is already running or the device-code request fails.
53
+ */
54
+ start(provider: string, spec: DeviceFlowSpec): Promise<DeviceAttempt>;
55
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * GitHub OAuth device-authorization flow (RFC 8628) for providers that cannot
3
+ * use the loopback redirect engine: no redirect URI, no PKCE, no client
4
+ * secret. The user opens a verification URL and types a short code while the
5
+ * plugin polls the token endpoint until GitHub releases the access token.
6
+ * The management model (one attempt per provider, `isBusy`/`pending`/`cancel`)
7
+ * mirrors {@link OAuthFlowManager} so the auth controller can treat both
8
+ * engines uniformly.
9
+ */
10
+ import { proxiedFetch } from '../http.js';
11
+ /** Default poll interval when the device-code response omits one. */
12
+ const DEFAULT_INTERVAL_SEC = 5;
13
+ /** Default device-code lifetime when the response omits one (GitHub: 15 minutes). */
14
+ const DEFAULT_EXPIRES_IN_SEC = 900;
15
+ /** Sleep for `ms`, rejecting early when the signal aborts. */
16
+ function sleep(ms, signal) {
17
+ return new Promise((resolve, reject) => {
18
+ if (signal.aborted) {
19
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
20
+ return;
21
+ }
22
+ const timer = setTimeout(() => {
23
+ signal.removeEventListener('abort', onAbort);
24
+ resolve();
25
+ }, ms);
26
+ timer.unref();
27
+ const onAbort = () => {
28
+ clearTimeout(timer);
29
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
30
+ };
31
+ signal.addEventListener('abort', onAbort, { once: true });
32
+ });
33
+ }
34
+ /**
35
+ * Own the set of in-flight device-flow attempts, keyed by provider. One
36
+ * attempt per provider at a time; an attempt removes itself when it settles.
37
+ */
38
+ export class DeviceFlowManager {
39
+ attempts = new Map();
40
+ /**
41
+ * Whether a device-flow attempt is running for one provider.
42
+ * @param provider - the provider route.
43
+ * @returns true while an attempt is polling.
44
+ */
45
+ isBusy(provider) {
46
+ return this.attempts.has(provider);
47
+ }
48
+ /**
49
+ * The pending attempt for one provider, when any.
50
+ * @param provider - the provider route.
51
+ * @returns the in-flight attempt, or `undefined`.
52
+ */
53
+ pending(provider) {
54
+ return this.attempts.get(provider);
55
+ }
56
+ /**
57
+ * Start a device-flow attempt: request a device code, then poll the token
58
+ * endpoint in the background of `waitToken`.
59
+ * @param provider - the provider route (one attempt at a time).
60
+ * @param spec - static flow facts for this provider.
61
+ * @returns the live attempt; its `waitToken()` settles the login.
62
+ * @throws when an attempt is already running or the device-code request fails.
63
+ */
64
+ async start(provider, spec) {
65
+ if (this.attempts.has(provider)) {
66
+ throw new Error(`a ${provider} login attempt is already in progress`);
67
+ }
68
+ const fetchFn = spec.fetchFn ?? proxiedFetch;
69
+ const response = await fetchFn(spec.deviceCodeUrl, {
70
+ method: 'POST',
71
+ headers: {
72
+ 'accept': 'application/json',
73
+ 'content-type': 'application/x-www-form-urlencoded',
74
+ },
75
+ body: new URLSearchParams({ client_id: spec.clientId, scope: spec.scope }).toString(),
76
+ });
77
+ if (!response.ok) {
78
+ throw new Error(`${provider} device-code request failed (HTTP ${String(response.status)})`);
79
+ }
80
+ const wire = await response.json();
81
+ if (typeof wire.device_code !== 'string' || wire.device_code.length === 0
82
+ || typeof wire.user_code !== 'string' || wire.user_code.length === 0
83
+ || typeof wire.verification_uri !== 'string' || wire.verification_uri.length === 0) {
84
+ throw new Error(`${provider} device-code response is missing device_code/user_code/verification_uri`);
85
+ }
86
+ const intervalSec = typeof wire.interval === 'number' && wire.interval > 0
87
+ ? wire.interval
88
+ : DEFAULT_INTERVAL_SEC;
89
+ const expiresInSec = typeof wire.expires_in === 'number' && wire.expires_in > 0
90
+ ? wire.expires_in
91
+ : DEFAULT_EXPIRES_IN_SEC;
92
+ const controller = new AbortController();
93
+ let resolveToken;
94
+ let rejectToken;
95
+ const tokenPromise = new Promise((resolve, reject) => {
96
+ resolveToken = resolve;
97
+ rejectToken = reject;
98
+ });
99
+ // The promise settles exactly once, from the poll loop below; an unhandled
100
+ // rejection must not surface if nobody awaited waitToken after a cancel.
101
+ tokenPromise.catch(() => undefined);
102
+ const settle = (error, token) => {
103
+ // Identity check: a late settle from a stale attempt (its poll loop or a
104
+ // cancel arriving after it already settled) must not kill a NEW attempt
105
+ // the user started for the same provider.
106
+ if (this.attempts.get(provider) !== attempt)
107
+ return;
108
+ this.attempts.delete(provider);
109
+ if (error !== undefined)
110
+ rejectToken(error);
111
+ else if (token !== undefined)
112
+ resolveToken(token);
113
+ };
114
+ const poll = async () => {
115
+ let intervalMs = intervalSec * 1000;
116
+ const deadline = Date.now() + expiresInSec * 1000;
117
+ while (true) {
118
+ await sleep(intervalMs, controller.signal);
119
+ if (Date.now() >= deadline) {
120
+ settle(new Error(`login timed out after ${String(Math.round(expiresInSec))}s`));
121
+ return;
122
+ }
123
+ const pollResponse = await fetchFn(spec.tokenUrl, {
124
+ method: 'POST',
125
+ headers: {
126
+ 'accept': 'application/json',
127
+ 'content-type': 'application/x-www-form-urlencoded',
128
+ },
129
+ body: new URLSearchParams({
130
+ client_id: spec.clientId,
131
+ device_code: wire.device_code,
132
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
133
+ }).toString(),
134
+ signal: controller.signal,
135
+ });
136
+ const result = await pollResponse.json();
137
+ if (typeof result.access_token === 'string' && result.access_token.length > 0) {
138
+ settle(undefined, result.access_token);
139
+ return;
140
+ }
141
+ switch (result.error) {
142
+ case 'authorization_pending':
143
+ break;
144
+ case 'slow_down':
145
+ // RFC 8628 §3.5: add five seconds to the poll interval.
146
+ intervalMs += 5000;
147
+ break;
148
+ case 'access_denied':
149
+ settle(new Error('login declined on the GitHub authorization page'));
150
+ return;
151
+ case 'expired_token':
152
+ settle(new Error('the device code expired before authorization completed'));
153
+ return;
154
+ default:
155
+ settle(new Error(`${provider} device-flow polling failed: ${result.error_description ?? result.error ?? `HTTP ${String(pollResponse.status)}`}`));
156
+ return;
157
+ }
158
+ }
159
+ };
160
+ const attempt = {
161
+ verificationUrl: wire.verification_uri,
162
+ userCode: wire.user_code,
163
+ waitToken: () => tokenPromise,
164
+ cancel: () => {
165
+ controller.abort(new Error('login cancelled'));
166
+ settle(new Error('login cancelled'));
167
+ },
168
+ };
169
+ this.attempts.set(provider, attempt);
170
+ void poll().catch((error) => {
171
+ // Aborts land here from sleep/fetch; everything else is a transport or
172
+ // parse failure worth surfacing as the login failure.
173
+ settle(error instanceof Error ? error : new Error(String(error)));
174
+ });
175
+ return attempt;
176
+ }
177
+ }
@@ -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
+ }