dsh-plugin-subscription-oauth 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 (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +400 -2
  3. package/cordis.patch.yml +13 -0
  4. package/lib/auth/device-flow.d.ts +112 -0
  5. package/lib/auth/device-flow.js +319 -0
  6. package/lib/auth/device-id.d.ts +31 -0
  7. package/lib/auth/device-id.js +58 -0
  8. package/lib/auth/flow.d.ts +33 -0
  9. package/lib/auth/flow.js +12 -0
  10. package/lib/auth/glm-flow.d.ts +99 -0
  11. package/lib/auth/glm-flow.js +194 -0
  12. package/lib/auth/headers.d.ts +61 -0
  13. package/lib/auth/headers.js +106 -0
  14. package/lib/auth/pkce-flow.d.ts +78 -0
  15. package/lib/auth/pkce-flow.js +205 -0
  16. package/lib/auth/provider-controller.d.ts +241 -0
  17. package/lib/auth/provider-controller.js +502 -0
  18. package/lib/auth/registry.d.ts +91 -0
  19. package/lib/auth/registry.js +149 -0
  20. package/lib/auth/rpc.d.ts +31 -0
  21. package/lib/auth/rpc.js +200 -0
  22. package/lib/auth/store.d.ts +99 -0
  23. package/lib/auth/store.js +213 -0
  24. package/lib/auth/tokens.d.ts +76 -0
  25. package/lib/auth/tokens.js +103 -0
  26. package/lib/client/SubscriptionsSection.d.ts +34 -0
  27. package/lib/client/SubscriptionsSection.js +742 -0
  28. package/lib/client/index.d.ts +36 -0
  29. package/lib/client/index.js +47 -0
  30. package/lib/client/locales.d.ts +187 -0
  31. package/lib/client/locales.js +185 -0
  32. package/lib/client/styles.d.ts +18 -0
  33. package/lib/client/styles.js +257 -0
  34. package/lib/client/subscriptions-rpc.d.ts +24 -0
  35. package/lib/client/subscriptions-rpc.js +44 -0
  36. package/lib/client.js +2078 -0
  37. package/lib/client.js.map +1 -0
  38. package/lib/constants.d.ts +79 -0
  39. package/lib/constants.js +91 -0
  40. package/lib/errors.d.ts +28 -0
  41. package/lib/errors.js +34 -0
  42. package/lib/http.d.ts +24 -0
  43. package/lib/http.js +98 -0
  44. package/lib/index.d.ts +57 -0
  45. package/lib/index.js +163 -0
  46. package/lib/protocol.d.ts +316 -0
  47. package/lib/protocol.js +29 -0
  48. package/lib/providers/antigravity-catalog.d.ts +53 -0
  49. package/lib/providers/antigravity-catalog.js +269 -0
  50. package/lib/providers/antigravity.d.ts +331 -0
  51. package/lib/providers/antigravity.js +817 -0
  52. package/lib/providers/auto.d.ts +105 -0
  53. package/lib/providers/auto.js +311 -0
  54. package/lib/providers/catalog.d.ts +69 -0
  55. package/lib/providers/catalog.js +78 -0
  56. package/lib/providers/common.d.ts +51 -0
  57. package/lib/providers/common.js +169 -0
  58. package/lib/providers/glm-provider.d.ts +41 -0
  59. package/lib/providers/glm-provider.js +165 -0
  60. package/lib/providers/glm.d.ts +159 -0
  61. package/lib/providers/glm.js +441 -0
  62. package/lib/providers/grok.d.ts +224 -0
  63. package/lib/providers/grok.js +696 -0
  64. package/lib/providers/kimi-provider.d.ts +45 -0
  65. package/lib/providers/kimi-provider.js +148 -0
  66. package/lib/providers/kimi.d.ts +151 -0
  67. package/lib/providers/kimi.js +396 -0
  68. package/lib/providers/responses-stream.d.ts +58 -0
  69. package/lib/providers/responses-stream.js +115 -0
  70. package/lib/providers/usage.d.ts +65 -0
  71. package/lib/providers/usage.js +309 -0
  72. package/lib/settings.d.ts +122 -0
  73. package/lib/settings.js +333 -0
  74. package/lib/translate/antigravity-schema.d.ts +24 -0
  75. package/lib/translate/antigravity-schema.js +174 -0
  76. package/lib/translate/antigravity.d.ts +184 -0
  77. package/lib/translate/antigravity.js +419 -0
  78. package/lib/translate/responses.d.ts +156 -0
  79. package/lib/translate/responses.js +395 -0
  80. package/lib/translate/sse.d.ts +27 -0
  81. package/lib/translate/sse.js +82 -0
  82. package/package.json +126 -4
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Kimi Code's OAuth 2.0 device authorization grant (RFC 8628).
3
+ *
4
+ * Kimi Code has no loopback redirect: authorization happens on
5
+ * `www.kimi.com`, and this side polls `auth.kimi.com` for the token. The token
6
+ * endpoint answers with a JWT whose payload carries the account identity, so
7
+ * the flow settles the account id from the tokens themselves.
8
+ *
9
+ * @module dsh-plugin-subscription-oauth/auth/device-flow
10
+ */
11
+ import { DEFAULT_DEVICE_FLOW_TTL_MS, DEFAULT_POLL_INTERVAL_MS, KIMI_CLIENT_ID, SLOW_DOWN_INCREMENT_MS, } from '../constants.js';
12
+ import { kimiIdentityHeaders } from './headers.js';
13
+ /** Error every abandoned attempt settles with. */
14
+ export const LOGIN_CANCELLED_MESSAGE = 'login cancelled';
15
+ /** Read a non-empty string field, or undefined. */
16
+ function stringField(value) {
17
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
18
+ }
19
+ /** Accept a number only when it is finite and in range. */
20
+ function positiveNumber(value) {
21
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
22
+ }
23
+ /**
24
+ * Turn a token-endpoint payload into a token set with an absolute expiry.
25
+ *
26
+ * `expires_in` must be a finite non-negative number: a missing or nonsensical
27
+ * lifetime would otherwise mint a credential the harness treats as valid
28
+ * forever.
29
+ *
30
+ * The stored instant is the token's REAL expiry, with no safety margin baked in.
31
+ * Kimi Code's access tokens live only 15 minutes, so subtracting the refresh
32
+ * margin here would report a third of that life as already gone — and the card
33
+ * that shows this field would claim "expired" minutes after a successful login.
34
+ * The margin belongs to the refresh trigger, not to the recorded fact.
35
+ *
36
+ * @param payload - decoded token-endpoint JSON.
37
+ * @param previousRefreshToken - refresh token to retain when the response omits one.
38
+ * @returns the normalised token set.
39
+ * @throws {Error} `Kimi token response missing required fields` when unusable.
40
+ */
41
+ export function parseTokenPayload(payload, previousRefreshToken) {
42
+ const record = (payload ?? {});
43
+ const accessToken = stringField(record.access_token);
44
+ const lifetime = record.expires_in;
45
+ const lifetimeOk = typeof lifetime === 'number' && Number.isFinite(lifetime) && lifetime >= 0;
46
+ const refreshToken = stringField(record.refresh_token) ?? previousRefreshToken;
47
+ if (accessToken === undefined || !lifetimeOk) {
48
+ throw new Error('Kimi token response missing required fields');
49
+ }
50
+ // A finite lifetime can still overflow the multiplication; an infinite expiry
51
+ // would be a credential the harness treats as valid forever.
52
+ const expiresAt = Date.now() + lifetime * 1000;
53
+ if (!Number.isFinite(expiresAt)) {
54
+ throw new Error('Kimi token response missing required fields');
55
+ }
56
+ return {
57
+ accessToken,
58
+ ...refreshToken === undefined ? {} : { refreshToken },
59
+ expiresAt,
60
+ };
61
+ }
62
+ /** Decode one base64url JWT segment into an object, or undefined. */
63
+ function decodeSegment(segment) {
64
+ if (segment === undefined || segment.length === 0)
65
+ return undefined;
66
+ try {
67
+ const parsed = JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
68
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
69
+ ? parsed
70
+ : undefined;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ }
76
+ /**
77
+ * Decode one JWT's payload claims.
78
+ * @param token - the JWT, or undefined.
79
+ * @returns the claims object, or undefined for an opaque or malformed token.
80
+ */
81
+ export function decodeJwtClaims(token) {
82
+ if (token === undefined)
83
+ return undefined;
84
+ const parts = token.split('.');
85
+ return parts.length < 2 ? undefined : decodeSegment(parts[1]);
86
+ }
87
+ /**
88
+ * Recover the account identity from Kimi's tokens.
89
+ *
90
+ * The access token is authoritative and the refresh token the fallback; a
91
+ * `user_id` claim outranks `sub` in either token because the CLI's sessions
92
+ * are keyed by user id while `sub` is the OAuth subject.
93
+ *
94
+ * @param accessToken - the issued access token.
95
+ * @param refreshToken - the issued refresh token, when the response carried one.
96
+ * @returns the account id and lowercased email, each when the tokens disclose it.
97
+ */
98
+ export function identityFromTokens(accessToken, refreshToken) {
99
+ const access = decodeJwtClaims(accessToken);
100
+ const refresh = decodeJwtClaims(refreshToken);
101
+ const accountId = stringField(access?.user_id)
102
+ ?? stringField(refresh?.user_id)
103
+ ?? stringField(access?.sub)
104
+ ?? stringField(refresh?.sub);
105
+ const email = (stringField(access?.email) ?? stringField(refresh?.email))?.toLowerCase();
106
+ return {
107
+ ...accountId === undefined ? {} : { accountId },
108
+ ...email === undefined ? {} : { email },
109
+ };
110
+ }
111
+ /** The single error a cancelled attempt settles with. */
112
+ function cancelledError() {
113
+ return new Error(LOGIN_CANCELLED_MESSAGE);
114
+ }
115
+ /** Sleep, settling immediately as a cancellation once the signal aborts. */
116
+ function sleep(ms, signal) {
117
+ return new Promise((resolve, reject) => {
118
+ if (signal.aborted) {
119
+ reject(cancelledError());
120
+ return;
121
+ }
122
+ const onAbort = () => {
123
+ clearTimeout(timer);
124
+ reject(cancelledError());
125
+ };
126
+ const timer = setTimeout(() => {
127
+ signal.removeEventListener('abort', onAbort);
128
+ resolve();
129
+ }, ms);
130
+ timer.unref?.();
131
+ signal.addEventListener('abort', onAbort, { once: true });
132
+ });
133
+ }
134
+ /** Decode a response body as JSON, tolerating an empty or non-JSON error page. */
135
+ async function readJson(response) {
136
+ const text = await response.text();
137
+ if (text.length === 0)
138
+ return undefined;
139
+ try {
140
+ return JSON.parse(text);
141
+ }
142
+ catch {
143
+ return undefined;
144
+ }
145
+ }
146
+ /** Human description of a failed OAuth response. */
147
+ function describeFailure(status, payload) {
148
+ const record = (payload ?? {});
149
+ return stringField(record.error_description) ?? stringField(record.error) ?? `HTTP ${status}`;
150
+ }
151
+ /** One user-cancellable device attempt: a poll loop plus its abort handle. */
152
+ class Attempt {
153
+ verificationUrl;
154
+ userCode;
155
+ expiresAt;
156
+ controller = new AbortController();
157
+ tokenPromise;
158
+ cancelled = false;
159
+ constructor(verificationUrl, userCode, expiresAt, poll) {
160
+ this.verificationUrl = verificationUrl;
161
+ this.userCode = userCode;
162
+ this.expiresAt = expiresAt;
163
+ this.tokenPromise = poll(this.controller.signal);
164
+ // A rejected token promise must not surface as an unhandled rejection when
165
+ // the caller stops caring about this attempt.
166
+ this.tokenPromise.catch(() => undefined);
167
+ }
168
+ waitToken() {
169
+ return this.tokenPromise;
170
+ }
171
+ cancel() {
172
+ if (this.cancelled)
173
+ return;
174
+ this.cancelled = true;
175
+ this.controller.abort();
176
+ }
177
+ }
178
+ /**
179
+ * Owns at most one in-flight device authorization per provider route.
180
+ *
181
+ * Only one attempt can settle into the login: a second `start()` supersedes
182
+ * the first, whose poll is aborted so it cannot overwrite the newer session.
183
+ */
184
+ export class DeviceFlowManager {
185
+ attempts = new Map();
186
+ /** Whether an authorization is currently being polled for this route. */
187
+ isBusy(provider) {
188
+ return this.attempts.has(provider);
189
+ }
190
+ /** The in-flight attempt for a route, when one exists. */
191
+ pending(provider) {
192
+ return this.attempts.get(provider);
193
+ }
194
+ /**
195
+ * Begin a device authorization.
196
+ * @param provider - provider route key owning this attempt.
197
+ * @param spec - endpoint host, device id, and transport.
198
+ * @returns the pending attempt, whose `verificationUrl`/`userCode` the caller shows.
199
+ */
200
+ async start(provider, spec) {
201
+ const fetchFn = spec.fetchFn ?? fetch;
202
+ const headers = kimiIdentityHeaders(spec.deviceId);
203
+ const authorization = await this.requestDeviceCode(provider, spec.host, headers, fetchFn);
204
+ const attempt = new Attempt(authorization.verificationUrl, authorization.userCode, Date.now() + authorization.expiresInMs, (signal) => this.poll(provider, spec, authorization, headers, fetchFn, signal));
205
+ this.attempts.set(provider, attempt);
206
+ // The poll owns its own lifetime; a superseded start() aborts this one.
207
+ void attempt.waitToken().catch(() => undefined).finally(() => { this.settle(provider, attempt); });
208
+ return attempt;
209
+ }
210
+ /** Drop the attempt record, but never one that a newer `start()` replaced. */
211
+ settle(provider, attempt) {
212
+ if (this.attempts.get(provider) === attempt)
213
+ this.attempts.delete(provider);
214
+ }
215
+ async requestDeviceCode(provider, host, headers, fetchFn) {
216
+ const response = await fetchFn(`${host}/api/oauth/device_authorization`, {
217
+ method: 'POST',
218
+ headers: { ...headers, accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
219
+ body: new URLSearchParams({ client_id: KIMI_CLIENT_ID }).toString(),
220
+ });
221
+ const payload = await readJson(response);
222
+ if (!response.ok) {
223
+ throw new Error(`${provider} device authorization failed: ${describeFailure(response.status, payload)}`);
224
+ }
225
+ const record = (payload ?? {});
226
+ const deviceCode = stringField(record.device_code);
227
+ const userCode = stringField(record.user_code);
228
+ const verificationUri = stringField(record.verification_uri);
229
+ if (deviceCode === undefined || userCode === undefined || verificationUri === undefined) {
230
+ throw new Error('Kimi device authorization response missing required fields');
231
+ }
232
+ return {
233
+ deviceCode,
234
+ userCode,
235
+ verificationUrl: stringField(record.verification_uri_complete) ?? verificationUri,
236
+ expiresInMs: positiveNumber(record.expires_in) === undefined
237
+ ? DEFAULT_DEVICE_FLOW_TTL_MS
238
+ : record.expires_in * 1000,
239
+ intervalMs: positiveNumber(record.interval) === undefined
240
+ ? DEFAULT_POLL_INTERVAL_MS
241
+ : record.interval * 1000,
242
+ };
243
+ }
244
+ async poll(provider, spec, authorization, headers, fetchFn, signal) {
245
+ const deadline = Date.now() + authorization.expiresInMs;
246
+ let intervalMs = authorization.intervalMs;
247
+ for (;;) {
248
+ if (signal.aborted)
249
+ throw cancelledError();
250
+ if (Date.now() >= deadline) {
251
+ throw new Error('Kimi device code expired before authorization completed');
252
+ }
253
+ await sleep(intervalMs, signal);
254
+ const response = await fetchFn(`${spec.host}/api/oauth/token`, {
255
+ method: 'POST',
256
+ headers: { ...headers, accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
257
+ body: new URLSearchParams({
258
+ client_id: KIMI_CLIENT_ID,
259
+ device_code: authorization.deviceCode,
260
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
261
+ }).toString(),
262
+ signal,
263
+ });
264
+ const payload = await readJson(response);
265
+ const record = (payload ?? {});
266
+ // Success is "a token arrived", not merely a 2xx: an error body can ride
267
+ // a 200, and reporting it as a malformed token would hide the real cause.
268
+ if (response.ok && stringField(record.access_token) !== undefined) {
269
+ return parseTokenPayload(payload);
270
+ }
271
+ switch (stringField(record.error)) {
272
+ case 'authorization_pending':
273
+ break;
274
+ case 'slow_down':
275
+ // RFC 8628 §3.5: back off by a fixed increment per `slow_down`.
276
+ intervalMs += SLOW_DOWN_INCREMENT_MS;
277
+ break;
278
+ case 'access_denied':
279
+ throw new Error('Kimi authorization was declined in the browser');
280
+ case 'expired_token':
281
+ throw new Error('Kimi device code expired before authorization completed');
282
+ default:
283
+ if (response.ok) {
284
+ // A 2xx with no error code and no token: let the parser name the
285
+ // missing fields rather than inventing a provider failure.
286
+ return parseTokenPayload(payload);
287
+ }
288
+ throw new Error(`${provider} device authorization failed: ${describeFailure(response.status, payload)}`);
289
+ }
290
+ }
291
+ }
292
+ }
293
+ /**
294
+ * Exchange a refresh token for a fresh access token.
295
+ * @param spec - endpoint host, device id, and transport.
296
+ * @param refreshToken - the refresh token to redeem.
297
+ * @returns the refreshed token set (retaining the previous refresh token when omitted).
298
+ */
299
+ export async function refreshKimiToken(spec, refreshToken) {
300
+ const fetchFn = spec.fetchFn ?? fetch;
301
+ const response = await fetchFn(`${spec.host}/api/oauth/token`, {
302
+ method: 'POST',
303
+ headers: {
304
+ ...kimiIdentityHeaders(spec.deviceId),
305
+ accept: 'application/json',
306
+ 'content-type': 'application/x-www-form-urlencoded',
307
+ },
308
+ body: new URLSearchParams({
309
+ client_id: KIMI_CLIENT_ID,
310
+ grant_type: 'refresh_token',
311
+ refresh_token: refreshToken,
312
+ }).toString(),
313
+ });
314
+ const payload = await readJson(response);
315
+ if (!response.ok) {
316
+ throw new Error(`Kimi token refresh failed: ${describeFailure(response.status, payload)}`);
317
+ }
318
+ return parseTokenPayload(payload, refreshToken);
319
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The per-machine device id Kimi Code's `X-Msh-Device-Id` header carries.
3
+ *
4
+ * The value is a 32-character lowercase hex UUID (the CLI's format: a v4 UUID
5
+ * with its dashes removed). It must survive restarts — a changing id looks
6
+ * like a new machine to the service — so it is persisted under the plugin's
7
+ * data directory with owner-only permissions.
8
+ *
9
+ * @module dsh-plugin-subscription-oauth/auth/device-id
10
+ */
11
+ /**
12
+ * Mint a new device id in the CLI's format.
13
+ * @returns 32 lowercase hex characters.
14
+ */
15
+ export declare function mintDeviceId(): string;
16
+ /**
17
+ * Read the persisted device id, minting and storing one on first use.
18
+ *
19
+ * A malformed or unreadable file is replaced rather than rejected: the header
20
+ * is an identity hint, and refusing to start would strand the plugin.
21
+ *
22
+ * @param directory - absolute plugin data directory.
23
+ * @returns the stable 32-hex device id.
24
+ */
25
+ export declare function loadOrCreateDeviceId(directory: string): Promise<string>;
26
+ /**
27
+ * Read and validate one stored device id.
28
+ * @param file - absolute path to the device-id file.
29
+ * @returns the stored id, or undefined when absent or malformed.
30
+ */
31
+ export declare function readDeviceId(file: string): Promise<string | undefined>;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The per-machine device id Kimi Code's `X-Msh-Device-Id` header carries.
3
+ *
4
+ * The value is a 32-character lowercase hex UUID (the CLI's format: a v4 UUID
5
+ * with its dashes removed). It must survive restarts — a changing id looks
6
+ * like a new machine to the service — so it is persisted under the plugin's
7
+ * data directory with owner-only permissions.
8
+ *
9
+ * @module dsh-plugin-subscription-oauth/auth/device-id
10
+ */
11
+ import { randomUUID } from 'node:crypto';
12
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
13
+ import path from 'node:path';
14
+ import { KIMI_DEVICE_ID_FILENAME } from '../constants.js';
15
+ /** Matches the CLI's id format: 32 lowercase hex characters. */
16
+ const DEVICE_ID_PATTERN = /^[0-9a-f]{32}$/;
17
+ /**
18
+ * Mint a new device id in the CLI's format.
19
+ * @returns 32 lowercase hex characters.
20
+ */
21
+ export function mintDeviceId() {
22
+ return randomUUID().replace(/-/g, '');
23
+ }
24
+ /**
25
+ * Read the persisted device id, minting and storing one on first use.
26
+ *
27
+ * A malformed or unreadable file is replaced rather than rejected: the header
28
+ * is an identity hint, and refusing to start would strand the plugin.
29
+ *
30
+ * @param directory - absolute plugin data directory.
31
+ * @returns the stable 32-hex device id.
32
+ */
33
+ export async function loadOrCreateDeviceId(directory) {
34
+ const file = path.join(directory, KIMI_DEVICE_ID_FILENAME);
35
+ const existing = await readDeviceId(file);
36
+ if (existing !== undefined)
37
+ return existing;
38
+ const created = mintDeviceId();
39
+ await mkdir(directory, { recursive: true });
40
+ await writeFile(file, `${created}\n`, { encoding: 'utf8', mode: 0o600 });
41
+ return created;
42
+ }
43
+ /**
44
+ * Read and validate one stored device id.
45
+ * @param file - absolute path to the device-id file.
46
+ * @returns the stored id, or undefined when absent or malformed.
47
+ */
48
+ export async function readDeviceId(file) {
49
+ let raw;
50
+ try {
51
+ raw = await readFile(file, 'utf8');
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ const value = raw.trim();
57
+ return DEVICE_ID_PATTERN.test(value) ? value : undefined;
58
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The interactive-login contract every provider route implements.
3
+ *
4
+ * A login engine owns at most one in-flight attempt per provider. Two shapes
5
+ * exist behind it: a device authorization (the browser shows a code and the
6
+ * engine polls) and a loopback authorization-code flow (the browser opens a URL
7
+ * and the engine receives the redirect). The controller above only ever sees
8
+ * this interface, so both behave identically from the Settings page's view.
9
+ *
10
+ * @module dsh-plugin-subscription-oauth/auth/flow
11
+ */
12
+ import type { LoginPrompt } from '../protocol.js';
13
+ import type { SessionBase } from './store.js';
14
+ /** One in-flight login. */
15
+ export interface AuthAttempt<S extends SessionBase> {
16
+ /** What the Settings page shows to finish this attempt. */
17
+ readonly prompt: LoginPrompt;
18
+ /** Resolves with the session once the user approves; rejects on failure/cancel. */
19
+ readonly session: Promise<S>;
20
+ /** Abandon the attempt; `session` rejects with a cancellation. */
21
+ cancel(): void;
22
+ }
23
+ /** Starts and tracks interactive logins for one provider. */
24
+ export interface AuthFlow<S extends SessionBase> {
25
+ /**
26
+ * Begin an attempt.
27
+ * @returns the live attempt, whose prompt the caller shows.
28
+ * @throws when an attempt is already running.
29
+ */
30
+ start(): Promise<AuthAttempt<S>>;
31
+ /** The in-flight attempt, when one exists. */
32
+ pending(): AuthAttempt<S> | undefined;
33
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The interactive-login contract every provider route implements.
3
+ *
4
+ * A login engine owns at most one in-flight attempt per provider. Two shapes
5
+ * exist behind it: a device authorization (the browser shows a code and the
6
+ * engine polls) and a loopback authorization-code flow (the browser opens a URL
7
+ * and the engine receives the redirect). The controller above only ever sees
8
+ * this interface, so both behave identically from the Settings page's view.
9
+ *
10
+ * @module dsh-plugin-subscription-oauth/auth/flow
11
+ */
12
+ export {};
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The ZCode CLI-style OAuth flow BigModel's coding plan signs in with.
3
+ *
4
+ * ZCode does not authorize by device code or by a loopback redirect. It asks its
5
+ * own backend for a *flow*: `init` returns an `authorize_url` plus a flow id, the
6
+ * user signs in at `bigmodel.cn` in a browser, and the callback is completed
7
+ * server-side by `zcode.z.ai`. The client then polls the flow until the backend
8
+ * reports it ready and hands back the tokens.
9
+ *
10
+ * That shape is what makes this implementable outside the ZCode app: nothing has
11
+ * to receive a `zcode://oauth/callback` custom-protocol redirect, and no app
12
+ * secret is involved. The poll is authenticated with a secret this client
13
+ * generates itself, so the flow belongs to whoever started it.
14
+ *
15
+ * Wire contract, read out of ZCode 3.14.4's host bundle and verified against the
16
+ * live endpoint:
17
+ *
18
+ * - `POST /api/v1/oauth/cli/init`, `Authorization: Bearer <64 hex>`,
19
+ * `{ "provider": "bigmodel" }`
20
+ * → `{ code: 0, data: { flow_id, poll_token, authorize_url, expires_at,
21
+ * poll_interval_sec } }`
22
+ * - `GET /api/v1/oauth/cli/poll/<flow_id>`, same bearer
23
+ * → `{ code: 0, data: { status: "pending" } }` while waiting, then
24
+ * `{ code: 0, data: { status: "ready", token, user, bigmodel: {
25
+ * access_token, refresh_token } } }`
26
+ *
27
+ * @module dsh-plugin-subscription-oauth/auth/glm-flow
28
+ */
29
+ import type { FetchFn } from '../http.js';
30
+ /** Backend that brokers the flow; the browser never talks to it directly. */
31
+ export declare const GLM_OAUTH_BASE = "https://zcode.z.ai";
32
+ /** Flow-initiation path on the broker. */
33
+ export declare const GLM_INIT_PATH = "/api/v1/oauth/cli/init";
34
+ /** Flow-poll path on the broker; the flow id is appended. */
35
+ export declare const GLM_POLL_PATH = "/api/v1/oauth/cli/poll";
36
+ /** Provider key the broker knows the China-region coding plan by. */
37
+ export declare const GLM_BROKER_PROVIDER = "bigmodel";
38
+ /** What one flow needs to talk to the broker (injectable for tests). */
39
+ export interface GlmFlowSpec {
40
+ /** Broker origin; defaults to {@link GLM_OAUTH_BASE}. */
41
+ readonly baseUrl?: string;
42
+ /** Fetch implementation; defaults to the global `fetch`. */
43
+ readonly fetchFn?: FetchFn;
44
+ /** Clock, for tests. */
45
+ readonly now?: () => number;
46
+ }
47
+ /** A flow that has been created and is waiting for the user to sign in. */
48
+ export interface GlmFlowStart {
49
+ /** Broker-side identity of this flow. */
50
+ readonly flowId: string;
51
+ /** URL to open in a browser. */
52
+ readonly authorizeUrl: string;
53
+ /** Epoch milliseconds this flow stops being accepted. */
54
+ readonly expiresAt: number;
55
+ /** Cadence the broker asks to be polled at. */
56
+ readonly pollIntervalMs: number;
57
+ /** Secret this client generated; the poll authenticates with it. */
58
+ readonly pollToken: string;
59
+ }
60
+ /** What a completed flow yields. */
61
+ export interface GlmTokenSet {
62
+ /** BigModel access token, sent as the bearer to the coding endpoint. */
63
+ readonly accessToken: string;
64
+ /**
65
+ * The ZCode JWT the plan is bound to.
66
+ *
67
+ * ZCode presents this as the API key for a plan it calls "start plan", which is
68
+ * the credential that exists before an account has minted a coding-plan key of
69
+ * its own.
70
+ */
71
+ readonly zcodeJwt: string;
72
+ /** Refresh token, when the broker returned one. */
73
+ readonly refreshToken?: string;
74
+ /** Epoch milliseconds the access token stops being usable. */
75
+ readonly expiresAt: number;
76
+ /** Stable account id, when the broker stated one. */
77
+ readonly accountId?: string;
78
+ /** Human label for the account row. */
79
+ readonly label?: string;
80
+ }
81
+ /**
82
+ * Create a flow and return the URL the user signs in at.
83
+ *
84
+ * @param spec - broker origin, fetch, and clock.
85
+ * @param signal - optional caller cancellation.
86
+ * @returns the live flow.
87
+ * @throws {Error} when the broker refuses or answers a shape this build cannot use.
88
+ */
89
+ export declare function startGlmFlow(spec?: GlmFlowSpec, signal?: AbortSignal): Promise<GlmFlowStart>;
90
+ /**
91
+ * Ask the broker once whether the flow completed.
92
+ *
93
+ * @param spec - broker origin, fetch, and clock.
94
+ * @param start - the flow to ask about.
95
+ * @param signal - optional caller cancellation.
96
+ * @returns the tokens once the flow is ready, or undefined while it is pending.
97
+ * @throws {Error} when the flow expired, failed, or the broker refused.
98
+ */
99
+ export declare function pollGlmFlow(spec: GlmFlowSpec, start: GlmFlowStart, signal?: AbortSignal): Promise<GlmTokenSet | undefined>;