dsh-subscriptions 0.1.4 → 0.1.5

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.
@@ -8,6 +8,7 @@
8
8
  * engines uniformly.
9
9
  */
10
10
  import { hostFetch } from '../http.js';
11
+ import { parseProviderJson } from '../providers/common.js';
11
12
  /** Default poll interval when the device-code response omits one. */
12
13
  const DEFAULT_INTERVAL_SEC = 5;
13
14
  /** Default device-code lifetime when the response omits one (GitHub: 15 minutes). */
@@ -77,7 +78,7 @@ export class DeviceFlowManager {
77
78
  if (!response.ok) {
78
79
  throw new Error(`${provider} device-code request failed (HTTP ${String(response.status)})`);
79
80
  }
80
- const wire = await response.json();
81
+ const wire = await parseProviderJson(response, 'device code request');
81
82
  if (typeof wire.device_code !== 'string' || wire.device_code.length === 0
82
83
  || typeof wire.user_code !== 'string' || wire.user_code.length === 0
83
84
  || typeof wire.verification_uri !== 'string' || wire.verification_uri.length === 0) {
@@ -133,7 +134,7 @@ export class DeviceFlowManager {
133
134
  }).toString(),
134
135
  signal: controller.signal,
135
136
  });
136
- const result = await pollResponse.json();
137
+ const result = await parseProviderJson(pollResponse, 'device token poll');
137
138
  if (typeof result.access_token === 'string' && result.access_token.length > 0) {
138
139
  settle(undefined, result.access_token);
139
140
  return;
@@ -152,7 +153,9 @@ export class DeviceFlowManager {
152
153
  settle(new Error('the device code expired before authorization completed'));
153
154
  return;
154
155
  default:
155
- settle(new Error(`${provider} device-flow polling failed: ${result.error_description ?? result.error ?? `HTTP ${String(pollResponse.status)}`}`));
156
+ // Safe means this failure path carries no provider-controlled free text,
157
+ // not that no secret can exist elsewhere in the system.
158
+ settle(new Error(`${provider} device-flow polling failed (HTTP ${String(pollResponse.status)}): [provider response body omitted]`));
156
159
  return;
157
160
  }
158
161
  }
@@ -12,6 +12,38 @@ export const DEFAULT_FLOW_TIMEOUT_MS = 180_000;
12
12
  const SUCCESS_PAGE = '<!doctype html><html><head><meta charset="utf-8"><title>Login successful</title></head>'
13
13
  + '<body style="font-family:sans-serif"><h1>Login successful</h1>'
14
14
  + '<p>You can close this tab and return to DeepSeek Harness.</p></body></html>';
15
+ function authorizationFailure(error) {
16
+ // Safe means this failure path carries no provider-controlled free text,
17
+ // not that no secret can exist elsewhere in the system.
18
+ // RFC 6749 §4.1.2.1 categories select local literals, never provider prose.
19
+ let category;
20
+ switch (error) {
21
+ case 'invalid_request':
22
+ category = 'invalid_request';
23
+ break;
24
+ case 'unauthorized_client':
25
+ category = 'unauthorized_client';
26
+ break;
27
+ case 'access_denied':
28
+ category = 'access_denied';
29
+ break;
30
+ case 'unsupported_response_type':
31
+ category = 'unsupported_response_type';
32
+ break;
33
+ case 'invalid_scope':
34
+ category = 'invalid_scope';
35
+ break;
36
+ case 'server_error':
37
+ category = 'server_error';
38
+ break;
39
+ case 'temporarily_unavailable':
40
+ category = 'temporarily_unavailable';
41
+ break;
42
+ }
43
+ return 'authorization failed'
44
+ + (category === undefined ? '' : ` (${category})`)
45
+ + ': [provider message omitted]';
46
+ }
15
47
  function failurePage(detail) {
16
48
  return '<!doctype html><html><head><meta charset="utf-8"><title>Login failed</title></head>'
17
49
  + `<body style="font-family:sans-serif"><h1>Login failed</h1><p>${detail.replace(/[<>&]/g, '')}</p></body></html>`;
@@ -138,19 +170,20 @@ export class OAuthFlowManager {
138
170
  response.end('not found');
139
171
  return;
140
172
  }
141
- const errorDescription = url.searchParams.get('error_description') ?? url.searchParams.get('error');
142
- if (errorDescription !== null) {
143
- response.writeHead(200, { 'content-type': 'text/html' });
144
- response.end(failurePage(errorDescription));
145
- settle(new Error(`authorization failed: ${errorDescription}`));
146
- return;
147
- }
148
173
  if (url.searchParams.get('state') !== input.state) {
149
- // A stray or replayed redirect must not kill the real attempt.
174
+ // A stray or replayed redirect must not kill the real attempt, even on error.
175
+ // RFC 6749 §4.1.2.1 requires errors to echo the state sent in the request.
150
176
  response.writeHead(400, { 'content-type': 'text/plain' });
151
177
  response.end('state mismatch');
152
178
  return;
153
179
  }
180
+ if (url.searchParams.has('error_description') || url.searchParams.has('error')) {
181
+ const detail = authorizationFailure(url.searchParams.get('error'));
182
+ response.writeHead(200, { 'content-type': 'text/html' });
183
+ response.end(failurePage(detail));
184
+ settle(new Error(detail));
185
+ return;
186
+ }
154
187
  const code = url.searchParams.get('code');
155
188
  if (code === null || code.length === 0) {
156
189
  response.writeHead(400, { 'content-type': 'text/plain' });
@@ -64,23 +64,6 @@ export interface ResetCreditView {
64
64
  /** Epoch milliseconds until which the grant is visible but not usable. */
65
65
  cooldownUntil?: number;
66
66
  }
67
- /** One model's default-effort picker state as answered by `modelDefaults`. */
68
- export interface ModelDefaultView {
69
- id: string;
70
- name: string;
71
- /** Advertised effort levels, in catalog order (empty when the model has no reasoning). */
72
- efforts: {
73
- id: string;
74
- name: string;
75
- }[];
76
- /** The user-configured default effort, when set. */
77
- configured?: string;
78
- }
79
- /** `modelDefaults` endpoint value: one provider's picker state. */
80
- export interface ModelDefaultsCatalog {
81
- provider: SubscriptionProvider;
82
- models: ModelDefaultView[];
83
- }
84
67
  /** Injected dependencies of {@link SubscriptionsSection} (slot `inject`). */
85
68
  export interface SubscriptionsSectionInjected {
86
69
  /** Generic logical-RPC caller over the Connection transport. */
@@ -102,63 +85,6 @@ export type SubscriptionsSectionProps = Partial<SubscriptionsSectionInjected>;
102
85
  export declare function clientVersionText(t: SubscriptionsSectionInjected['t'], clientVersion: ProviderStatus['clientVersion']): string;
103
86
  /** Meter fill color lives with the pace logic: fresh 90%+ is red, pace presets add a yellow lead threshold. */
104
87
  export { usageBarColor } from './usage-pace.js';
105
- /** What one provider's collapsible default-effort section renders. */
106
- export interface ModelDefaultsView {
107
- /** Models with reasoning levels, after the name filter — one row each. */
108
- shown: ModelDefaultView[];
109
- /** Models with reasoning levels before filtering (the header total). */
110
- total: number;
111
- /** How many of those carry a user override (the header count). */
112
- overridden: number;
113
- /** Models without reasoning levels: one count line, never a row each. */
114
- withoutEfforts: number;
115
- /** Whether the list is long enough to deserve a filter box. */
116
- showFilter: boolean;
117
- }
118
- /**
119
- * Derive one provider's default-effort section from its catalog and filter.
120
- * Pure so the collapsed-header counts and the filter stay testable without a
121
- * DOM: rows come only from models that advertise levels, the count of the rest
122
- * rides as one line, and the filter matches display name or model id.
123
- * @param models - the provider's catalog models, or undefined while loading.
124
- * @param filter - the raw filter input (trimmed and lowercased here).
125
- * @returns the section's rows and header counts.
126
- */
127
- export declare function deriveModelDefaultsView(models: readonly ModelDefaultView[] | undefined, filter: string): ModelDefaultsView;
128
- /** Inputs of the default-effort fetch decision (see {@link shouldFetchModelDefaults}). */
129
- export interface ModelDefaultsFetchInput {
130
- /** Providers that currently have at least one account. */
131
- loggedIn: readonly SubscriptionProvider[];
132
- /** Providers whose disclosure is open. */
133
- open: readonly SubscriptionProvider[];
134
- /** The account signature the last completed fetch was answered for. */
135
- loadedFor: string | undefined;
136
- /** The account signature of the current status snapshot. */
137
- signature: string;
138
- /** Whether the last attempt failed (a failure latches until Retry). */
139
- failed: boolean;
140
- }
141
- /**
142
- * Whether the default-effort catalog needs (re)fetching.
143
- *
144
- * Fetching is gated on an *attempt* signature rather than on the payload
145
- * being empty: an empty answer is a legitimate result (a narrowed
146
- * `config.providers`, or a catalog that is momentarily unavailable), and
147
- * treating it as "not loaded yet" re-ran this effect forever. The signature
148
- * also covers the accounts, so logging a second provider in refetches
149
- * instead of leaving that card on the previous answer.
150
- * @param input - the decision inputs.
151
- * @returns true when the caller should start a fetch.
152
- */
153
- export declare function shouldFetchModelDefaults(input: ModelDefaultsFetchInput): boolean;
154
- /**
155
- * Stable signature of the accounts a catalog answer depends on. A change
156
- * means a previous answer is stale (an account arrived or left), so the next
157
- * open disclosure refetches.
158
- * @param statuses - the per-provider status snapshot.
159
- * @returns a signature string, stable across renders with equal accounts.
160
- */
161
- export declare function modelDefaultsSignature(statuses: Partial<Record<SubscriptionProvider, ProviderStatus>>): string;
162
88
  /**
163
89
  * The Subscriptions settings page component.
164
90
  * @param props - the slot inject face ({@link SubscriptionsSectionInjected}).
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * Subscriptions settings section: one card per subscription provider with an
4
- * OAuth login/logout flow driven by the node half's `/subscriptions-auth` RPC
5
- * channel. Login state lives server-side; the page polls `status` only while
4
+ * OAuth login/logout flow driven by the node half's
5
+ * `/api/subscriptions-auth.<endpoint>` RPC routes. Login state lives server-side; the page polls `status` only while
6
6
  * a login attempt is busy, so an idle page never polls. All state is local
7
7
  * React state — the page has no store.
8
8
  *
@@ -29,11 +29,6 @@ import { callSubscriptionsAuth, SubscriptionsAuthError } from './subscriptions-r
29
29
  export { callSubscriptionsAuth } from './subscriptions-rpc.js';
30
30
  /** Poll cadence while a provider login attempt is busy. */
31
31
  const POLL_INTERVAL_MS = 2000;
32
- /**
33
- * Model count above which the expanded default-effort list also offers a name
34
- * filter; below it the list is short enough to scan.
35
- */
36
- const MODEL_FILTER_THRESHOLD = 8;
37
32
  const CLIENT_VERSION_SOURCES = {
38
33
  npm: 'clientVersionNpm',
39
34
  local: 'clientVersionLocal',
@@ -192,65 +187,6 @@ function usageWindowLabel(t, window) {
192
187
  }
193
188
  /** Meter fill color lives with the pace logic: fresh 90%+ is red, pace presets add a yellow lead threshold. */
194
189
  export { usageBarColor } from './usage-pace.js';
195
- /**
196
- * Derive one provider's default-effort section from its catalog and filter.
197
- * Pure so the collapsed-header counts and the filter stay testable without a
198
- * DOM: rows come only from models that advertise levels, the count of the rest
199
- * rides as one line, and the filter matches display name or model id.
200
- * @param models - the provider's catalog models, or undefined while loading.
201
- * @param filter - the raw filter input (trimmed and lowercased here).
202
- * @returns the section's rows and header counts.
203
- */
204
- export function deriveModelDefaultsView(models, filter) {
205
- const all = models ?? [];
206
- const withEfforts = all.filter(model => model.efforts.length > 0);
207
- const query = filter.trim().toLowerCase();
208
- const shown = query === ''
209
- ? withEfforts
210
- : withEfforts.filter(model => model.name.toLowerCase().includes(query)
211
- || model.id.toLowerCase().includes(query));
212
- return {
213
- shown,
214
- total: withEfforts.length,
215
- overridden: withEfforts.filter(model => model.configured !== undefined).length,
216
- withoutEfforts: all.length - withEfforts.length,
217
- showFilter: withEfforts.length > MODEL_FILTER_THRESHOLD,
218
- };
219
- }
220
- /**
221
- * Whether the default-effort catalog needs (re)fetching.
222
- *
223
- * Fetching is gated on an *attempt* signature rather than on the payload
224
- * being empty: an empty answer is a legitimate result (a narrowed
225
- * `config.providers`, or a catalog that is momentarily unavailable), and
226
- * treating it as "not loaded yet" re-ran this effect forever. The signature
227
- * also covers the accounts, so logging a second provider in refetches
228
- * instead of leaving that card on the previous answer.
229
- * @param input - the decision inputs.
230
- * @returns true when the caller should start a fetch.
231
- */
232
- export function shouldFetchModelDefaults(input) {
233
- if (input.failed)
234
- return false;
235
- if (input.loggedIn.length === 0)
236
- return false;
237
- // Only an open disclosure pays for the per-model live resolve.
238
- if (!input.open.some(provider => input.loggedIn.includes(provider)))
239
- return false;
240
- return input.loadedFor !== input.signature;
241
- }
242
- /**
243
- * Stable signature of the accounts a catalog answer depends on. A change
244
- * means a previous answer is stale (an account arrived or left), so the next
245
- * open disclosure refetches.
246
- * @param statuses - the per-provider status snapshot.
247
- * @returns a signature string, stable across renders with equal accounts.
248
- */
249
- export function modelDefaultsSignature(statuses) {
250
- return PROVIDERS
251
- .map(({ id }) => `${id}:${(statuses[id]?.accounts ?? []).map(account => account.key).sort().join(',')}`)
252
- .join('|');
253
- }
254
190
  /**
255
191
  * The Subscriptions settings page component.
256
192
  * @param props - the slot inject face ({@link SubscriptionsSectionInjected}).